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.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-cf-tcp.yml +26 -2
- package/.github/workflows/deploy-cf.yml +26 -0
- package/CHANGELOG.md +289 -0
- package/README.md +153 -20
- package/apps/aws-stack/bin/aws.ts +36 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +221 -15
- package/apps/aws-stack/tests/stack.test.ts +450 -16
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -2
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +256 -79
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +17 -4
- package/apps/cf-worker/package.json +2 -2
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +86 -9
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +23 -2
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/package.json +1 -1
- package/docs/AWS-Deployment.md +123 -22
- package/docs/AWS-TCP-Deployment.md +37 -2
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +9 -2
- package/docs/Cloudflare-TCP-Deployment.md +135 -52
- package/docs/SASL-EXTERNAL.md +175 -0
- package/package.json +3 -3
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
- package/packages/aws-adapter/src/config-loader.ts +134 -6
- package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
- package/packages/aws-adapter/src/handlers/connect.ts +47 -1
- package/packages/aws-adapter/src/handlers/default.ts +95 -6
- package/packages/aws-adapter/src/handlers/index.ts +31 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/serialize.ts +8 -0
- package/packages/aws-adapter/src/tables.ts +9 -0
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
- package/packages/aws-adapter/tests/connect.test.ts +199 -2
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
- package/packages/aws-adapter/tests/handlers.test.ts +57 -1
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +48 -9
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +154 -21
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +47 -5
- package/packages/cf-adapter/src/env.ts +88 -0
- package/packages/cf-adapter/src/index.ts +17 -1
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +40 -1
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +146 -2
- package/packages/irc-core/src/commands/sasl.ts +136 -19
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +393 -20
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +8 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +395 -36
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +228 -13
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
- package/packages/irc-core/tests/commands/oper.test.ts +560 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +463 -1
- package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
- package/packages/irc-core/tests/ports.test.ts +99 -7
- package/packages/irc-core/tests/services-store.test.ts +376 -14
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -8
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +544 -7
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/scripts/package.json +1 -1
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +17 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- 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
|
|
294
|
-
*
|
|
295
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|