serverless-ircd 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-aws.yml +156 -32
- package/.github/workflows/deploy-cf-tcp.yml +35 -9
- package/.github/workflows/deploy-cf.yml +40 -14
- package/CHANGELOG.md +594 -0
- package/README.md +286 -60
- package/apps/aws-stack/README.md +3 -5
- package/apps/aws-stack/bin/aws.ts +118 -9
- package/apps/aws-stack/cdk.json +0 -3
- package/apps/aws-stack/package.json +3 -4
- package/apps/aws-stack/src/aws-stack.ts +398 -67
- package/apps/aws-stack/src/static-site.ts +323 -0
- package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
- package/apps/aws-stack/tests/stack.test.ts +714 -105
- package/apps/aws-stack/tests/static-site.test.ts +491 -0
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -3
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +267 -87
- 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 +18 -14
- package/apps/cf-worker/package.json +3 -4
- 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 +95 -77
- 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 +44 -19
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/landing/favicon.ico +0 -0
- package/apps/web/landing/index.html +1 -0
- package/apps/web/package.json +2 -2
- package/apps/web/scripts/build.mjs +66 -4
- package/apps/web/src/build-env.ts +125 -4
- package/apps/web/src/config-schema.ts +20 -6
- package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
- package/apps/web/tests/build-env.test.ts +210 -9
- package/apps/web/tests/build-smoke.test.ts +2 -2
- package/apps/web/tests/config-schema.test.ts +149 -25
- package/docs/AWS-Deployment.md +793 -118
- package/docs/AWS-TCP-Deployment.md +57 -47
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +95 -114
- package/docs/Cloudflare-TCP-Deployment.md +160 -101
- package/docs/Release-Process.md +27 -23
- package/docs/SASL-EXTERNAL.md +175 -0
- package/docs/Services.md +69 -22
- package/docs/WebClientGuide.md +35 -26
- package/package.json +7 -10
- 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 +39 -16
- package/packages/aws-adapter/src/config-loader.ts +153 -8
- package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
- package/packages/aws-adapter/src/handlers/connect.ts +73 -1
- package/packages/aws-adapter/src/handlers/default.ts +279 -123
- package/packages/aws-adapter/src/handlers/index.ts +98 -25
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
- package/packages/aws-adapter/src/index.ts +5 -7
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
- package/packages/aws-adapter/src/serialize.ts +23 -0
- package/packages/aws-adapter/src/tables.ts +11 -12
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -2
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
- package/packages/aws-adapter/tests/connect.test.ts +323 -3
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
- package/packages/aws-adapter/tests/handlers.test.ts +174 -12
- package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
- package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
- package/packages/aws-adapter/tests/stats.test.ts +0 -3
- package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
- package/packages/aws-adapter/tests/tables.test.ts +1 -8
- package/packages/aws-adapter/tests/transactions.test.ts +0 -1
- package/packages/cf-adapter/package.json +1 -5
- package/packages/cf-adapter/src/cf-runtime.ts +100 -10
- package/packages/cf-adapter/src/channel-do.ts +13 -3
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +406 -116
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +105 -26
- package/packages/cf-adapter/src/env.ts +99 -10
- package/packages/cf-adapter/src/index.ts +17 -7
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
- package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
- 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-coverage.test.ts +460 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
- package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
- 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 +245 -3
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/serialize.test.ts +25 -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/account-migration.ts +140 -0
- 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/account-auth.ts +16 -19
- 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/memoserv.ts +1 -1
- 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 +161 -11
- 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 +148 -4
- package/packages/irc-core/src/commands/sasl.ts +154 -46
- 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 +424 -25
- package/packages/irc-core/src/credential-hashing.ts +11 -54
- 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 +9 -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 +529 -190
- 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 +266 -23
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/account-migration.test.ts +133 -0
- 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/markread.test.ts +54 -0
- package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
- package/packages/irc-core/tests/commands/oper.test.ts +574 -1
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +602 -133
- package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
- 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/message-store.test.ts +5 -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 +212 -12
- package/packages/irc-core/tests/ports.test.ts +170 -7
- package/packages/irc-core/tests/services-store.test.ts +567 -1
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-core/vitest.config.ts +6 -1
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -22
- 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 +563 -54
- 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/pnpm-workspace.yaml +1 -0
- package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
- package/scripts/deploy-web-aws.mjs +290 -0
- package/scripts/package.json +23 -0
- package/scripts/tsconfig.test.json +12 -0
- package/scripts/vitest.config.ts +19 -0
- 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 +19 -0
- package/tools/ci-hardening/src/validate.ts +57 -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-aws-oidc.test.ts +96 -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/tests/validate.test.ts +42 -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/migrate-accounts-to-services.ts +270 -0
- package/tools/package.json +2 -1
- package/tools/seed-aws-accounts.ts +35 -10
- package/tools/seed-cf-accounts.ts +42 -9
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/packages/aws-adapter/src/account-store.ts +0 -121
- package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
- package/packages/aws-adapter/tests/account-store.test.ts +0 -276
- package/packages/cf-adapter/src/d1-account-store.ts +0 -198
- package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
- package/packages/irc-core/tests/account-store.test.ts +0 -131
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
-
type AccountStore,
|
|
3
2
|
type AwayStore,
|
|
4
3
|
CapturingLogger,
|
|
4
|
+
type CertIdentity,
|
|
5
5
|
type ChanName,
|
|
6
6
|
type ChanSnapshot,
|
|
7
7
|
type ChannelDelta,
|
|
@@ -19,15 +19,15 @@ import {
|
|
|
19
19
|
InMemoryServicesStore,
|
|
20
20
|
LogLevel,
|
|
21
21
|
type Logger,
|
|
22
|
+
MAX_TAGS,
|
|
22
23
|
type MessageStore,
|
|
23
24
|
type MonitorStore,
|
|
24
25
|
type MtlsIdentityProvider,
|
|
25
26
|
type Nick,
|
|
26
27
|
type NickHistoryStore,
|
|
27
28
|
Numerics,
|
|
29
|
+
OperFailureStats,
|
|
28
30
|
type RawLine,
|
|
29
|
-
type SaslPayload,
|
|
30
|
-
type SaslResult,
|
|
31
31
|
SequentialIdFactory,
|
|
32
32
|
type ServerConfig,
|
|
33
33
|
type ServerStats,
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
type ServicesStore,
|
|
36
36
|
type StoredMessage,
|
|
37
37
|
applyChannelDelta as applyDelta,
|
|
38
|
+
certIdentityFromSubject,
|
|
38
39
|
createChannel,
|
|
39
40
|
createConnection,
|
|
40
41
|
encodeBase64,
|
|
@@ -116,6 +117,9 @@ class FakeRuntime implements IrcRuntime {
|
|
|
116
117
|
async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
|
|
117
118
|
this.calls.push({ method: 'broadcastWallops', args: [lines, except] });
|
|
118
119
|
}
|
|
120
|
+
async broadcastOperNotice(lines: RawLine[], except?: ConnId): Promise<void> {
|
|
121
|
+
this.calls.push({ method: 'broadcastOperNotice', args: [lines, except] });
|
|
122
|
+
}
|
|
119
123
|
async disconnect(conn: ConnId, reason?: string): Promise<void> {
|
|
120
124
|
this.calls.push({ method: 'disconnect', args: [conn, reason] });
|
|
121
125
|
}
|
|
@@ -219,7 +223,6 @@ function makeActor(opts: {
|
|
|
219
223
|
logger?: Logger;
|
|
220
224
|
traceId?: string;
|
|
221
225
|
messages?: MessageStore;
|
|
222
|
-
accounts?: AccountStore;
|
|
223
226
|
mtlsIdentity?: MtlsIdentityProvider;
|
|
224
227
|
history?: NickHistoryStore;
|
|
225
228
|
stats?: ServerStats;
|
|
@@ -230,6 +233,8 @@ function makeActor(opts: {
|
|
|
230
233
|
monitor?: MonitorStore;
|
|
231
234
|
away?: AwayStore;
|
|
232
235
|
services?: ServicesStore;
|
|
236
|
+
operFailures?: OperFailureStats;
|
|
237
|
+
sasl?: ServerConfig['sasl'];
|
|
233
238
|
}): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
|
|
234
239
|
const clock = opts.clock ?? new FakeClock(1_000);
|
|
235
240
|
const conn = createConnection({ id: 'c1', connectedSince: 0 });
|
|
@@ -256,6 +261,7 @@ function makeActor(opts: {
|
|
|
256
261
|
quitMessage: 'Client Quit',
|
|
257
262
|
...(opts.floodControl !== undefined ? { floodControl: opts.floodControl } : {}),
|
|
258
263
|
...(opts.operCreds !== undefined ? { operCreds: opts.operCreds } : {}),
|
|
264
|
+
...(opts.sasl !== undefined ? { sasl: opts.sasl } : {}),
|
|
259
265
|
};
|
|
260
266
|
const ids = new SequentialIdFactory();
|
|
261
267
|
type ActorOpts = ConstructorParameters<typeof ConnectionActor>[0];
|
|
@@ -280,9 +286,6 @@ function makeActor(opts: {
|
|
|
280
286
|
if (opts.messages !== undefined) {
|
|
281
287
|
finalOpts.messages = opts.messages;
|
|
282
288
|
}
|
|
283
|
-
if (opts.accounts !== undefined) {
|
|
284
|
-
finalOpts.accounts = opts.accounts;
|
|
285
|
-
}
|
|
286
289
|
if (opts.mtlsIdentity !== undefined) {
|
|
287
290
|
finalOpts.mtlsIdentity = opts.mtlsIdentity;
|
|
288
291
|
}
|
|
@@ -310,6 +313,9 @@ function makeActor(opts: {
|
|
|
310
313
|
if (opts.services !== undefined) {
|
|
311
314
|
finalOpts.services = opts.services;
|
|
312
315
|
}
|
|
316
|
+
if (opts.operFailures !== undefined) {
|
|
317
|
+
finalOpts.operFailures = opts.operFailures;
|
|
318
|
+
}
|
|
313
319
|
const actor = new ConnectionActor(finalOpts);
|
|
314
320
|
return { actor, runtime, conn };
|
|
315
321
|
}
|
|
@@ -588,6 +594,27 @@ describe('ConnectionActor — malformed input', () => {
|
|
|
588
594
|
expect(firstLines[0]?.text).toContain('BOGUS');
|
|
589
595
|
});
|
|
590
596
|
|
|
597
|
+
it('emits 421 with the too-many-tags reason for a 16-tag frame and continues', async () => {
|
|
598
|
+
const logger = new CapturingLogger();
|
|
599
|
+
const { actor, runtime } = makeActor({ logger });
|
|
600
|
+
const tags = Array.from({ length: MAX_TAGS + 1 }, (_, i) => `t${i}`);
|
|
601
|
+
await actor.receiveTextFrame(`@${tags.join(';')} PRIVMSG #foo :hi\r\nPING :tok\r\n`);
|
|
602
|
+
|
|
603
|
+
// Parse-error log carries the stable reason code, never the raw line
|
|
604
|
+
// (the tag section is attacker-controlled payload).
|
|
605
|
+
const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
|
|
606
|
+
expect(errs).toHaveLength(1);
|
|
607
|
+
expect(errs[0]?.fields?.reason).toBe('too-many-tags');
|
|
608
|
+
expect(JSON.stringify(errs[0])).not.toContain('PRIVMSG');
|
|
609
|
+
// Connection continues: 421 for the offending frame, PONG for the next.
|
|
610
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
611
|
+
expect(sends).toHaveLength(2);
|
|
612
|
+
const firstLines = sends[0]?.args[1] as RawLine[];
|
|
613
|
+
expect(firstLines[0]?.text).toContain('421');
|
|
614
|
+
const secondLines = sends[1]?.args[1] as RawLine[];
|
|
615
|
+
expect(secondLines[0]?.text).toBe('PONG :tok');
|
|
616
|
+
});
|
|
617
|
+
|
|
591
618
|
it('does not crash when the frame is an empty string', async () => {
|
|
592
619
|
const { actor, runtime } = makeActor({});
|
|
593
620
|
await expect(actor.receiveTextFrame('')).resolves.toBeUndefined();
|
|
@@ -650,6 +677,147 @@ describe('ConnectionActor — routing', () => {
|
|
|
650
677
|
});
|
|
651
678
|
});
|
|
652
679
|
|
|
680
|
+
describe('ConnectionActor — maxTargetsPerCommand cap on multi-target commands', () => {
|
|
681
|
+
// makeActor() configures serverConfig.maxTargetsPerCommand = 10.
|
|
682
|
+
const CAP = 10;
|
|
683
|
+
const OVER = 27;
|
|
684
|
+
|
|
685
|
+
/** Builds a comma-separated target list of `count` `<prefix><NN>` names. */
|
|
686
|
+
function csv(count: number, prefix: string): string {
|
|
687
|
+
return Array.from(
|
|
688
|
+
{ length: count },
|
|
689
|
+
(_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`,
|
|
690
|
+
).join(',');
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Same list, space-separated (USERHOST/ISON take separate params). */
|
|
694
|
+
function spaced(count: number, prefix: string): string {
|
|
695
|
+
return csv(count, prefix).split(',').join(' ');
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Collects the `send` payloads whose lines carry the 407 numeric. */
|
|
699
|
+
function tooManyTargetSends(runtime: FakeRuntime): RawLine[][] {
|
|
700
|
+
return runtime.calls
|
|
701
|
+
.filter(
|
|
702
|
+
(c) =>
|
|
703
|
+
c.method === 'send' && (c.args[1] as RawLine[]).some((l) => l.text.includes(' 407 ')),
|
|
704
|
+
)
|
|
705
|
+
.map((c) => c.args[1] as RawLine[]);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
it('caps multi-target PRIVMSG at maxTargetsPerCommand and 407s the dropped targets', async () => {
|
|
709
|
+
// Flood control disabled: the default token bucket would otherwise
|
|
710
|
+
// silently drop per-target reducer calls past its capacity, masking
|
|
711
|
+
// the target cap under test.
|
|
712
|
+
const { actor, runtime } = makeActor({ floodControl: null });
|
|
713
|
+
runtime.calls.length = 0;
|
|
714
|
+
|
|
715
|
+
await actor.receiveTextFrame(`PRIVMSG ${csv(OVER, 'n')} :hi\r\n`);
|
|
716
|
+
|
|
717
|
+
// Only the first CAP targets fan out.
|
|
718
|
+
const sendsToNick = runtime.calls.filter((c) => c.method === 'sendToNick');
|
|
719
|
+
expect(sendsToNick).toHaveLength(CAP);
|
|
720
|
+
expect(sendsToNick[0]?.args[0]).toBe('n01');
|
|
721
|
+
expect(sendsToNick[CAP - 1]?.args[0]).toBe('n10');
|
|
722
|
+
|
|
723
|
+
// Each dropped target gets a dedicated 407 ERR_TOOMANYTARGETS line.
|
|
724
|
+
const drops = tooManyTargetSends(runtime);
|
|
725
|
+
expect(drops).toHaveLength(OVER - CAP);
|
|
726
|
+
expect(drops[0]?.[0]?.text).toContain(' 407 alice n11 ');
|
|
727
|
+
expect(drops[0]?.[0]?.text).toContain(':Too many targets');
|
|
728
|
+
expect(drops[OVER - CAP - 1]?.[0]?.text).toContain('n27');
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('multi-target PRIVMSG below the cap is unaffected (no 407)', async () => {
|
|
732
|
+
const { actor, runtime } = makeActor({ floodControl: null });
|
|
733
|
+
runtime.calls.length = 0;
|
|
734
|
+
|
|
735
|
+
await actor.receiveTextFrame('PRIVMSG a,b,c :hi\r\n');
|
|
736
|
+
|
|
737
|
+
expect(runtime.calls.filter((c) => c.method === 'sendToNick')).toHaveLength(3);
|
|
738
|
+
expect(tooManyTargetSends(runtime)).toHaveLength(0);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('caps multi-target NOTICE at maxTargetsPerCommand and 407s the dropped targets', async () => {
|
|
742
|
+
const { actor, runtime } = makeActor({ floodControl: null });
|
|
743
|
+
runtime.calls.length = 0;
|
|
744
|
+
|
|
745
|
+
await actor.receiveTextFrame(`NOTICE ${csv(OVER, 'n')} :hi\r\n`);
|
|
746
|
+
|
|
747
|
+
expect(runtime.calls.filter((c) => c.method === 'sendToNick')).toHaveLength(CAP);
|
|
748
|
+
const drops = tooManyTargetSends(runtime);
|
|
749
|
+
expect(drops).toHaveLength(OVER - CAP);
|
|
750
|
+
expect(drops[0]?.[0]?.text).toContain(' 407 alice n11 ');
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('caps multi-target TAGMSG at maxTargetsPerCommand and 407s the dropped targets', async () => {
|
|
754
|
+
const { actor, runtime } = makeActor({
|
|
755
|
+
floodControl: null,
|
|
756
|
+
conn: { caps: new Set<string>(['message-tags']) },
|
|
757
|
+
});
|
|
758
|
+
runtime.calls.length = 0;
|
|
759
|
+
|
|
760
|
+
await actor.receiveTextFrame(`@+typing=active TAGMSG ${csv(OVER, 'n')}\r\n`);
|
|
761
|
+
|
|
762
|
+
expect(runtime.calls.filter((c) => c.method === 'sendToNick')).toHaveLength(CAP);
|
|
763
|
+
expect(tooManyTargetSends(runtime)).toHaveLength(OVER - CAP);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it('caps multi-channel PART at maxTargetsPerCommand and 407s the dropped channels', async () => {
|
|
767
|
+
const { actor, runtime } = makeActor({ floodControl: null });
|
|
768
|
+
for (let i = 1; i <= OVER; i++) {
|
|
769
|
+
const name = `#c${String(i).padStart(2, '0')}` as ChanName;
|
|
770
|
+
await runtime.applyChannelDelta(name, {
|
|
771
|
+
memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
runtime.calls.length = 0;
|
|
775
|
+
|
|
776
|
+
await actor.receiveTextFrame(`PART ${csv(OVER, '#c')} :bye\r\n`);
|
|
777
|
+
|
|
778
|
+
// Only the first CAP parts apply.
|
|
779
|
+
const parts = runtime.calls.filter((c) => c.method === 'applyChannelDelta');
|
|
780
|
+
expect(parts).toHaveLength(CAP);
|
|
781
|
+
const drops = tooManyTargetSends(runtime);
|
|
782
|
+
expect(drops).toHaveLength(OVER - CAP);
|
|
783
|
+
expect(drops[0]?.[0]?.text).toContain(' 407 alice #c11 ');
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('407s dropped targets with the * placeholder when the connection has no nick yet', async () => {
|
|
787
|
+
const { actor, runtime, conn } = makeActor({ floodControl: null });
|
|
788
|
+
conn.registration = 'pre-registration';
|
|
789
|
+
// biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes
|
|
790
|
+
delete conn.nick;
|
|
791
|
+
runtime.calls.length = 0;
|
|
792
|
+
|
|
793
|
+
await actor.receiveTextFrame(`NOTICE ${csv(OVER, 'n')} :hi\r\n`);
|
|
794
|
+
|
|
795
|
+
const drops = tooManyTargetSends(runtime);
|
|
796
|
+
expect(drops).toHaveLength(OVER - CAP);
|
|
797
|
+
expect(drops[0]?.[0]?.text).toContain(' 407 * n11 ');
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('USERHOST still resolves at most maxTargetsPerCommand nicks (regression guard)', async () => {
|
|
801
|
+
const { actor, runtime } = makeActor({ floodControl: null });
|
|
802
|
+
runtime.calls.length = 0;
|
|
803
|
+
|
|
804
|
+
await actor.receiveTextFrame(`USERHOST ${spaced(OVER, 'n')}\r\n`);
|
|
805
|
+
|
|
806
|
+
expect(runtime.calls.filter((c) => c.method === 'lookupNick')).toHaveLength(CAP);
|
|
807
|
+
expect(tooManyTargetSends(runtime)).toHaveLength(0);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it('ISON still resolves at most maxTargetsPerCommand nicks (regression guard)', async () => {
|
|
811
|
+
const { actor, runtime } = makeActor({ floodControl: null });
|
|
812
|
+
runtime.calls.length = 0;
|
|
813
|
+
|
|
814
|
+
await actor.receiveTextFrame(`ISON ${spaced(OVER, 'n')}\r\n`);
|
|
815
|
+
|
|
816
|
+
expect(runtime.calls.filter((c) => c.method === 'lookupNick')).toHaveLength(CAP);
|
|
817
|
+
expect(tooManyTargetSends(runtime)).toHaveLength(0);
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
|
|
653
821
|
describe('ConnectionActor — secure transport (user mode `S`)', () => {
|
|
654
822
|
it('marks the connection state secure when constructed with secure: true', () => {
|
|
655
823
|
const { conn } = makeActor({ secure: true });
|
|
@@ -1283,6 +1451,53 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1283
1451
|
}
|
|
1284
1452
|
});
|
|
1285
1453
|
|
|
1454
|
+
it('rejects pre-registration OPER with 451 ERR_NOTREGISTERED without granting oper', async () => {
|
|
1455
|
+
const { actor, runtime, conn } = makeActor({
|
|
1456
|
+
conn: { registration: 'pre-registration' },
|
|
1457
|
+
operCreds: [{ user: 'alice', password: 'secret' }],
|
|
1458
|
+
});
|
|
1459
|
+
// Strip the registration handshake fields: a genuine pre-registration
|
|
1460
|
+
// client has no nick/user yet. Object.assign is the exactOptionalPropertyTypes
|
|
1461
|
+
// escape hatch (a `nick: undefined` literal or `delete` is rejected).
|
|
1462
|
+
Object.assign(conn, { nick: undefined, user: undefined });
|
|
1463
|
+
runtime.calls.length = 0;
|
|
1464
|
+
|
|
1465
|
+
// Valid credentials: without the gate the reducer would grant oper here.
|
|
1466
|
+
await actor.receiveTextFrame('OPER alice secret\r\n');
|
|
1467
|
+
|
|
1468
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1469
|
+
expect(sends).toHaveLength(1);
|
|
1470
|
+
const lines = sends[0]?.args[1] as RawLine[];
|
|
1471
|
+
expect(lines[0]?.text).toBe(':irc.example.com 451 * :You have not registered');
|
|
1472
|
+
expect(conn.userModes.oper).toBe(false);
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
it('grants oper with 381 once the same connection completes registration after a gated attempt', async () => {
|
|
1476
|
+
const { actor, runtime, conn } = makeActor({
|
|
1477
|
+
conn: { registration: 'pre-registration' },
|
|
1478
|
+
operCreds: [{ user: 'alice', password: 'secret' }],
|
|
1479
|
+
});
|
|
1480
|
+
Object.assign(conn, { nick: undefined, user: undefined });
|
|
1481
|
+
runtime.calls.length = 0;
|
|
1482
|
+
|
|
1483
|
+
await actor.receiveTextFrame('OPER alice secret\r\n');
|
|
1484
|
+
expect(conn.userModes.oper).toBe(false);
|
|
1485
|
+
|
|
1486
|
+
// Complete the handshake; the gate must not stick.
|
|
1487
|
+
await actor.receiveTextFrame('NICK alice\r\n');
|
|
1488
|
+
await actor.receiveTextFrame('USER alice 0 * :Alice\r\n');
|
|
1489
|
+
expect(conn.registration).toBe('registered');
|
|
1490
|
+
runtime.calls.length = 0;
|
|
1491
|
+
|
|
1492
|
+
await actor.receiveTextFrame('OPER alice secret\r\n');
|
|
1493
|
+
|
|
1494
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
1495
|
+
expect(sends).toHaveLength(2);
|
|
1496
|
+
const firstLines = sends[0]?.args[1] as RawLine[];
|
|
1497
|
+
expect(firstLines[0]?.text).toBe(':irc.example.com 381 alice :You are now an IRC operator');
|
|
1498
|
+
expect(conn.userModes.oper).toBe(true);
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1286
1501
|
it('emits 421 with empty command when routeSingleChannel gets no channel (TOPIC)', async () => {
|
|
1287
1502
|
const { actor, runtime } = makeActor({});
|
|
1288
1503
|
await actor.receiveTextFrame('TOPIC\r\n');
|
|
@@ -1386,6 +1601,56 @@ describe('ConnectionActor — additional routing', () => {
|
|
|
1386
1601
|
}
|
|
1387
1602
|
});
|
|
1388
1603
|
|
|
1604
|
+
it('threads the shared per-IP OPER failure tracker: 3 failures then 491 + broadcastOperNotice', async () => {
|
|
1605
|
+
const clock = new FakeClock(1_000);
|
|
1606
|
+
const tracker = new OperFailureStats(clock);
|
|
1607
|
+
const { actor, runtime } = makeActor({
|
|
1608
|
+
clock,
|
|
1609
|
+
operCreds: [{ user: 'alice', password: 'secret' }],
|
|
1610
|
+
operFailures: tracker,
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
for (let i = 0; i < 3; i++) {
|
|
1614
|
+
runtime.calls.length = 0;
|
|
1615
|
+
await actor.receiveTextFrame(`OPER alice :wrong-${i}\r\n`);
|
|
1616
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
1617
|
+
expect(sends).toHaveLength(1);
|
|
1618
|
+
expect((sends[0]?.args[1] as RawLine[])[0]?.text).toContain(' 464 ');
|
|
1619
|
+
// No oper notice until the lockout actually trips.
|
|
1620
|
+
expect(runtime.calls.some((c) => c.method === 'broadcastOperNotice')).toBe(false);
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
runtime.calls.length = 0;
|
|
1624
|
+
await actor.receiveTextFrame('OPER alice :secret\r\n');
|
|
1625
|
+
|
|
1626
|
+
// Locked out: 491 to the connection (never 464 — the credential store
|
|
1627
|
+
// was not consulted) and one oper-notice fanout to every +o connection.
|
|
1628
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
1629
|
+
expect(sends).toHaveLength(1);
|
|
1630
|
+
expect((sends[0]?.args[1] as RawLine[])[0]?.text).toBe(
|
|
1631
|
+
':irc.example.com 491 alice :No O-lines for your host',
|
|
1632
|
+
);
|
|
1633
|
+
const notices = runtime.calls.filter((c) => c.method === 'broadcastOperNotice');
|
|
1634
|
+
expect(notices).toHaveLength(1);
|
|
1635
|
+
const lines = notices[0]?.args[0] as RawLine[];
|
|
1636
|
+
expect(lines[0]?.text).toBe(
|
|
1637
|
+
':irc.example.com NOTICE * :OPER lockout triggered for example.com',
|
|
1638
|
+
);
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
it('keeps OPER unlocked when no tracker is bound to the actor', async () => {
|
|
1642
|
+
const { actor, runtime } = makeActor({
|
|
1643
|
+
operCreds: [{ user: 'alice', password: 'secret' }],
|
|
1644
|
+
});
|
|
1645
|
+
for (let i = 0; i < 5; i++) {
|
|
1646
|
+
await actor.receiveTextFrame('OPER alice :wrong\r\n');
|
|
1647
|
+
}
|
|
1648
|
+
runtime.calls.length = 0;
|
|
1649
|
+
await actor.receiveTextFrame('OPER alice :secret\r\n');
|
|
1650
|
+
const sends = runtime.calls.filter((c) => c.method === 'send' && c.args[0] === 'c1');
|
|
1651
|
+
expect((sends[0]?.args[1] as RawLine[])[0]?.text).toContain(' 381 ');
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1389
1654
|
it('routes REHASH from an oper through rehashReducer (382, never 421) and re-invokes the loader', async () => {
|
|
1390
1655
|
const { actor, runtime, conn } = makeActor({
|
|
1391
1656
|
conn: {
|
|
@@ -1899,7 +2164,7 @@ describe('ConnectionActor — observability', () => {
|
|
|
1899
2164
|
}
|
|
1900
2165
|
});
|
|
1901
2166
|
|
|
1902
|
-
it('emits a warn-level frame.parse-error record
|
|
2167
|
+
it('emits a warn-level frame.parse-error record with token/length/reason', async () => {
|
|
1903
2168
|
const logger = new CapturingLogger();
|
|
1904
2169
|
const { actor } = makeActor({ logger, traceId: 't1' });
|
|
1905
2170
|
|
|
@@ -1910,7 +2175,53 @@ describe('ConnectionActor — observability', () => {
|
|
|
1910
2175
|
expect(errs).toHaveLength(1);
|
|
1911
2176
|
expect(errs[0]?.level).toBe(LogLevel.Warn);
|
|
1912
2177
|
expect(errs[0]?.traceId).toBe('t1');
|
|
1913
|
-
expect(
|
|
2178
|
+
expect(errs[0]?.fields?.token).toBe('12345');
|
|
2179
|
+
expect(errs[0]?.fields?.length).toBe('12345'.length);
|
|
2180
|
+
expect(errs[0]?.fields?.reason).toBe('invalid-command-token');
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
it('never includes the raw line in a frame.parse-error record', async () => {
|
|
2184
|
+
const logger = new CapturingLogger();
|
|
2185
|
+
const { actor } = makeActor({ logger, traceId: 't2' });
|
|
2186
|
+
|
|
2187
|
+
// The 5-digit command trips the parser while the trailing PASS
|
|
2188
|
+
// payload carries a credential: a hostile client can smuggle secret
|
|
2189
|
+
// material into the warn record unless the payload is scrubbed. The
|
|
2190
|
+
// record must carry no `line` field and no trace of the secret.
|
|
2191
|
+
await actor.receiveTextFrame('12345 PASS hunter2\r\n');
|
|
2192
|
+
|
|
2193
|
+
const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
|
|
2194
|
+
expect(errs).toHaveLength(1);
|
|
2195
|
+
expect(errs[0]?.fields?.line).toBeUndefined();
|
|
2196
|
+
expect(JSON.stringify(errs[0])).not.toContain('hunter2');
|
|
2197
|
+
});
|
|
2198
|
+
|
|
2199
|
+
it('truncates an over-long leading token in a parse-error record', async () => {
|
|
2200
|
+
const logger = new CapturingLogger();
|
|
2201
|
+
const { actor } = makeActor({ logger, traceId: 't3' });
|
|
2202
|
+
|
|
2203
|
+
// 32 letters + a digit is an illegal command token (mixed) AND the
|
|
2204
|
+
// 33-char token exceeds the 32-char log cap.
|
|
2205
|
+
await actor.receiveTextFrame(`${'X'.repeat(32)}9 PASS hunter2\r\n`);
|
|
2206
|
+
|
|
2207
|
+
const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
|
|
2208
|
+
expect(errs).toHaveLength(1);
|
|
2209
|
+
expect(errs[0]?.fields?.token).toBe(`${'X'.repeat(32)}...`);
|
|
2210
|
+
expect(JSON.stringify(errs[0])).not.toContain('hunter2');
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
it('reports the input-cap reason for an over-long line', async () => {
|
|
2214
|
+
const logger = new CapturingLogger();
|
|
2215
|
+
const { actor } = makeActor({ logger, traceId: 't4' });
|
|
2216
|
+
|
|
2217
|
+
// 600 bytes exceeds the 512-byte input cap → the byte-cap branch of
|
|
2218
|
+
// the parser fires before the command-token check.
|
|
2219
|
+
await actor.receiveTextFrame(`${'A'.repeat(600)}\r\n`);
|
|
2220
|
+
|
|
2221
|
+
const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
|
|
2222
|
+
expect(errs).toHaveLength(1);
|
|
2223
|
+
expect(errs[0]?.fields?.reason).toBe('input-cap-exceeded');
|
|
2224
|
+
expect(errs[0]?.fields?.length).toBe(600);
|
|
1914
2225
|
});
|
|
1915
2226
|
|
|
1916
2227
|
it('falls back to a NoopLogger and unknown traceId when omitted', async () => {
|
|
@@ -2129,17 +2440,7 @@ describe('ConnectionActor — CHATHISTORY', () => {
|
|
|
2129
2440
|
// SASL / AccountStore — actor plumbing (ctx.accounts threaded through buildCtx)
|
|
2130
2441
|
// ===========================================================================
|
|
2131
2442
|
|
|
2132
|
-
describe('ConnectionActor — SASL
|
|
2133
|
-
/** Capture-and-respond fake AccountStore (mirrors the irc-core sasl tests). */
|
|
2134
|
-
class FakeAccountStore implements AccountStore {
|
|
2135
|
-
readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
|
|
2136
|
-
constructor(private readonly result: SaslResult) {}
|
|
2137
|
-
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
2138
|
-
this.calls.push({ mech, payload });
|
|
2139
|
-
return this.result;
|
|
2140
|
-
}
|
|
2141
|
-
}
|
|
2142
|
-
|
|
2443
|
+
describe('ConnectionActor — SASL ServicesStore plumbing', () => {
|
|
2143
2444
|
/** Builds the PLAIN base64 payload `authzid\0authcid\0password`. */
|
|
2144
2445
|
function plainPayload(authcid: string, password: string): string {
|
|
2145
2446
|
return encodeBase64(`\0${authcid}\0${password}`);
|
|
@@ -2154,11 +2455,12 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
|
|
|
2154
2455
|
};
|
|
2155
2456
|
}
|
|
2156
2457
|
|
|
2157
|
-
it('completes SASL PLAIN with 900/903 when a bound
|
|
2158
|
-
const
|
|
2458
|
+
it('completes SASL PLAIN with 900/903 when a bound ServicesStore accepts creds', async () => {
|
|
2459
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
|
|
2460
|
+
services.registerNick('alice', 'secret', 'alice@example.com');
|
|
2159
2461
|
const { actor, runtime, conn } = makeActor({
|
|
2160
2462
|
conn: saslReadyConn(),
|
|
2161
|
-
|
|
2463
|
+
services,
|
|
2162
2464
|
});
|
|
2163
2465
|
conn.nick = 'alice';
|
|
2164
2466
|
runtime.calls.length = 0;
|
|
@@ -2173,18 +2475,14 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
|
|
|
2173
2475
|
expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
|
|
2174
2476
|
// Critical: never 904 for valid creds.
|
|
2175
2477
|
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
2176
|
-
// The AccountStore was consulted with the parsed PLAIN credentials.
|
|
2177
|
-
expect(accounts.calls).toEqual([
|
|
2178
|
-
{ mech: 'PLAIN', payload: { kind: 'PLAIN', username: 'alice', password: 'secret' } },
|
|
2179
|
-
]);
|
|
2180
2478
|
// Account name recorded on the connection for extended-join/account-tag.
|
|
2181
2479
|
expect(conn.account).toBe('alice');
|
|
2182
2480
|
});
|
|
2183
2481
|
|
|
2184
|
-
it('emits 904 ERR_SASLFAIL when no
|
|
2482
|
+
it('emits 904 ERR_SASLFAIL when no ServicesStore is bound (regression guard)', async () => {
|
|
2185
2483
|
const { actor, runtime, conn } = makeActor({
|
|
2186
2484
|
conn: saslReadyConn(),
|
|
2187
|
-
// No `
|
|
2485
|
+
// No `services` option → ctx.services is undefined.
|
|
2188
2486
|
});
|
|
2189
2487
|
conn.nick = 'alice';
|
|
2190
2488
|
runtime.calls.length = 0;
|
|
@@ -2202,11 +2500,12 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
|
|
|
2202
2500
|
expect(conn.account).toBeUndefined();
|
|
2203
2501
|
});
|
|
2204
2502
|
|
|
2205
|
-
it('emits 904 when a bound
|
|
2206
|
-
const
|
|
2503
|
+
it('emits 904 when a bound ServicesStore rejects the credentials', async () => {
|
|
2504
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
|
|
2505
|
+
services.registerNick('alice', 'secret', 'alice@example.com');
|
|
2207
2506
|
const { actor, runtime, conn } = makeActor({
|
|
2208
2507
|
conn: saslReadyConn(),
|
|
2209
|
-
|
|
2508
|
+
services,
|
|
2210
2509
|
});
|
|
2211
2510
|
conn.nick = 'alice';
|
|
2212
2511
|
runtime.calls.length = 0;
|
|
@@ -2223,20 +2522,10 @@ describe('ConnectionActor — SASL AccountStore plumbing', () => {
|
|
|
2223
2522
|
});
|
|
2224
2523
|
|
|
2225
2524
|
describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
|
|
2226
|
-
/** Fake
|
|
2227
|
-
class FakeAccountStore implements AccountStore {
|
|
2228
|
-
readonly calls: Array<{ mech: string; payload: SaslPayload }> = [];
|
|
2229
|
-
constructor(private readonly result: SaslResult) {}
|
|
2230
|
-
verify(mech: string, payload: SaslPayload): SaslResult {
|
|
2231
|
-
this.calls.push({ mech, payload });
|
|
2232
|
-
return this.result;
|
|
2233
|
-
}
|
|
2234
|
-
}
|
|
2235
|
-
|
|
2236
|
-
/** Fake MtlsIdentityProvider returning a canned subject for 'c1'. */
|
|
2525
|
+
/** Fake MtlsIdentityProvider returning a canned cert identity for 'c1'. */
|
|
2237
2526
|
class FakeMtlsProvider implements MtlsIdentityProvider {
|
|
2238
|
-
constructor(private readonly identity:
|
|
2239
|
-
getIdentity(connId: string):
|
|
2527
|
+
constructor(private readonly identity: CertIdentity | undefined) {}
|
|
2528
|
+
getIdentity(connId: string): CertIdentity | undefined {
|
|
2240
2529
|
return connId === 'c1' ? this.identity : undefined;
|
|
2241
2530
|
}
|
|
2242
2531
|
}
|
|
@@ -2249,13 +2538,19 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
|
|
|
2249
2538
|
};
|
|
2250
2539
|
}
|
|
2251
2540
|
|
|
2252
|
-
it('completes SASL EXTERNAL with 900/903 when mTLS provider and store accept', async () => {
|
|
2253
|
-
const
|
|
2254
|
-
|
|
2541
|
+
it('completes SASL EXTERNAL with 900/903 when mTLS provider, operator opt-in, secure transport, and store accept', async () => {
|
|
2542
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
|
|
2543
|
+
services.registerNick('alice', 'secret', 'alice@example.com');
|
|
2544
|
+
services.addCertFP('alice', 'CN=alice');
|
|
2545
|
+
const mtlsIdentity = new FakeMtlsProvider(certIdentityFromSubject('CN=alice'));
|
|
2255
2546
|
const { actor, runtime, conn } = makeActor({
|
|
2256
2547
|
conn: saslReadyConn(),
|
|
2257
|
-
|
|
2548
|
+
services,
|
|
2258
2549
|
mtlsIdentity,
|
|
2550
|
+
// The EXTERNAL triple gate: mTLS provider bound (above) +
|
|
2551
|
+
// operator opt-in + a secure (TLS) connection.
|
|
2552
|
+
sasl: { externalEnabled: true },
|
|
2553
|
+
secure: true,
|
|
2259
2554
|
});
|
|
2260
2555
|
conn.nick = 'alice';
|
|
2261
2556
|
runtime.calls.length = 0;
|
|
@@ -2268,17 +2563,16 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
|
|
|
2268
2563
|
expect(lines.some((l) => l.text.includes(' 900 '))).toBe(true);
|
|
2269
2564
|
expect(lines.some((l) => l.text.includes(' 903 '))).toBe(true);
|
|
2270
2565
|
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
2271
|
-
expect(accounts.calls).toEqual([
|
|
2272
|
-
{ mech: 'EXTERNAL', payload: { kind: 'EXTERNAL', identity: 'CN=alice' } },
|
|
2273
|
-
]);
|
|
2274
2566
|
expect(conn.account).toBe('alice');
|
|
2275
2567
|
});
|
|
2276
2568
|
|
|
2277
2569
|
it('returns 908 for EXTERNAL when no mTLS provider is bound', async () => {
|
|
2278
|
-
const
|
|
2570
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
|
|
2571
|
+
services.registerNick('alice', 'secret', 'alice@example.com');
|
|
2572
|
+
services.addCertFP('alice', 'CN=alice');
|
|
2279
2573
|
const { actor, runtime, conn } = makeActor({
|
|
2280
2574
|
conn: saslReadyConn(),
|
|
2281
|
-
|
|
2575
|
+
services,
|
|
2282
2576
|
// No mtlsIdentity → EXTERNAL should 908 at mechanism phase.
|
|
2283
2577
|
});
|
|
2284
2578
|
conn.nick = 'alice';
|
|
@@ -2292,6 +2586,31 @@ describe('ConnectionActor — SASL EXTERNAL + mTLS plumbing', () => {
|
|
|
2292
2586
|
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
2293
2587
|
expect(conn.account).toBeUndefined();
|
|
2294
2588
|
});
|
|
2589
|
+
|
|
2590
|
+
it('answers 908 when the operator opt-in is missing even with mTLS and a secure connection', async () => {
|
|
2591
|
+
const services = new InMemoryServicesStore({ clock: new FakeClock(0) });
|
|
2592
|
+
services.registerNick('alice', 'secret', 'alice@example.com');
|
|
2593
|
+
services.addCertFP('alice', 'CN=alice');
|
|
2594
|
+
const mtlsIdentity = new FakeMtlsProvider(certIdentityFromSubject('CN=alice'));
|
|
2595
|
+
// mTLS provider bound + secure connection, but sasl.externalEnabled
|
|
2596
|
+
// left at the default (off) → EXTERNAL is refused with 908.
|
|
2597
|
+
const { actor, runtime, conn } = makeActor({
|
|
2598
|
+
conn: saslReadyConn(),
|
|
2599
|
+
services,
|
|
2600
|
+
mtlsIdentity,
|
|
2601
|
+
secure: true,
|
|
2602
|
+
});
|
|
2603
|
+
conn.nick = 'alice';
|
|
2604
|
+
runtime.calls.length = 0;
|
|
2605
|
+
|
|
2606
|
+
await actor.receiveTextFrame('AUTHENTICATE EXTERNAL\r\n');
|
|
2607
|
+
|
|
2608
|
+
const sends = runtime.calls.filter((c) => c.method === 'send');
|
|
2609
|
+
const lines = sends.flatMap((s) => s.args[1] as RawLine[]);
|
|
2610
|
+
expect(lines.some((l) => l.text.includes(' 908 '))).toBe(true);
|
|
2611
|
+
expect(lines.some((l) => l.text.includes(' 904 '))).toBe(false);
|
|
2612
|
+
expect(conn.account).toBeUndefined();
|
|
2613
|
+
});
|
|
2295
2614
|
});
|
|
2296
2615
|
|
|
2297
2616
|
describe('ConnectionActor — server-info commands (VERSION/TIME/ADMIN/INFO/USERHOST/ISON)', () => {
|
|
@@ -3999,6 +4318,196 @@ describe('ConnectionActor — draft/multiline BATCH', () => {
|
|
|
3999
4318
|
).accumulateMultilineLine({ command: 'PRIVMSG', params: ['#foo', 'hi'] });
|
|
4000
4319
|
expect(result).toEqual([]);
|
|
4001
4320
|
});
|
|
4321
|
+
|
|
4322
|
+
it('aborts the batch mid-stream when an inner line pushes it over the byte budget (no close needed)', async () => {
|
|
4323
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4324
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
|
|
4325
|
+
// Tiny budget: 6 bytes + separator + 6 bytes = 13 > 10 on the second push.
|
|
4326
|
+
(actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
|
|
4327
|
+
...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
|
|
4328
|
+
multilineMaxBytes: 10,
|
|
4329
|
+
};
|
|
4330
|
+
await setupMultilineChannel(runtime, [
|
|
4331
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4332
|
+
{ conn: 'c2', nick: 'bob' },
|
|
4333
|
+
]);
|
|
4334
|
+
runtime.calls.length = 0;
|
|
4335
|
+
|
|
4336
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4337
|
+
await actor.receiveTextFrame('PRIVMSG #foo :aaaaaa\r\n');
|
|
4338
|
+
// No `BATCH -m1` close — the over-budget push itself must abort the batch.
|
|
4339
|
+
await actor.receiveTextFrame('PRIVMSG #foo :bbbbbb\r\n');
|
|
4340
|
+
|
|
4341
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4342
|
+
// A synthesized `BATCH -m1` closes the aborted batch client-side...
|
|
4343
|
+
expect(aliceLines.some((l) => l.text === 'BATCH -m1')).toBe(true);
|
|
4344
|
+
// ...followed by the FAIL rejection.
|
|
4345
|
+
expect(
|
|
4346
|
+
aliceLines.some(
|
|
4347
|
+
(l) =>
|
|
4348
|
+
l.text ===
|
|
4349
|
+
':irc.example.com FAIL BATCH MULTILINE_MAX_BYTES #foo :Multi-line batch exceeds the 10 byte limit',
|
|
4350
|
+
),
|
|
4351
|
+
).toBe(true);
|
|
4352
|
+
// Nothing was relayed to the channel.
|
|
4353
|
+
expect(runtime.calls.some((c) => c.method === 'broadcast')).toBe(false);
|
|
4354
|
+
// The accumulator is gone: a late `BATCH -m1` close hits the unknown-id 421 path.
|
|
4355
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4356
|
+
expect(sentTo(runtime, 'c1').some((l) => l.text.includes(' 421 '))).toBe(true);
|
|
4357
|
+
});
|
|
4358
|
+
|
|
4359
|
+
it('accepts an inner line that lands exactly at the byte budget (boundary is inclusive)', async () => {
|
|
4360
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4361
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4362
|
+
// Budget 13: 6 bytes + separator + 6 bytes = 13 → accepted.
|
|
4363
|
+
(actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
|
|
4364
|
+
...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
|
|
4365
|
+
multilineMaxBytes: 13,
|
|
4366
|
+
};
|
|
4367
|
+
await setupMultilineChannel(runtime, [
|
|
4368
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4369
|
+
{ conn: 'c2', nick: 'bob' },
|
|
4370
|
+
]);
|
|
4371
|
+
runtime.calls.length = 0;
|
|
4372
|
+
|
|
4373
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4374
|
+
await actor.receiveTextFrame('PRIVMSG #foo :aaaaaa\r\n');
|
|
4375
|
+
await actor.receiveTextFrame('PRIVMSG #foo :cccccc\r\n');
|
|
4376
|
+
expect(sentTo(runtime, 'c1').some((l) => l.text.includes('FAIL BATCH'))).toBe(false);
|
|
4377
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4378
|
+
|
|
4379
|
+
// The at-budget batch finalizes normally: bob gets the joined PRIVMSG.
|
|
4380
|
+
const bobLines = sentTo(runtime, 'c2');
|
|
4381
|
+
expect(
|
|
4382
|
+
bobLines.some((l) => l.text === ':alice!alice@example.com PRIVMSG #foo :aaaaaa\ncccccc'),
|
|
4383
|
+
).toBe(true);
|
|
4384
|
+
});
|
|
4385
|
+
|
|
4386
|
+
it('rejects the 101st inner line by entry count even when under the byte budget', async () => {
|
|
4387
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4388
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
|
|
4389
|
+
await setupMultilineChannel(runtime, [
|
|
4390
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4391
|
+
{ conn: 'c2', nick: 'bob' },
|
|
4392
|
+
]);
|
|
4393
|
+
runtime.calls.length = 0;
|
|
4394
|
+
|
|
4395
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4396
|
+
// 100 single-byte entries: joined bytes 199 < 4096, all accepted.
|
|
4397
|
+
await actor.receiveTextFrame('PRIVMSG #foo :x\r\n'.repeat(100));
|
|
4398
|
+
expect(sentTo(runtime, 'c1').some((l) => l.text.includes('FAIL BATCH'))).toBe(false);
|
|
4399
|
+
|
|
4400
|
+
// The 101st push trips the defense-in-depth entry cap.
|
|
4401
|
+
await actor.receiveTextFrame('PRIVMSG #foo :x\r\n');
|
|
4402
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4403
|
+
expect(aliceLines.some((l) => l.text === 'BATCH -m1')).toBe(true);
|
|
4404
|
+
expect(
|
|
4405
|
+
aliceLines.some(
|
|
4406
|
+
(l) =>
|
|
4407
|
+
l.text ===
|
|
4408
|
+
':irc.example.com FAIL BATCH MULTILINE_MAX_LINES #foo :Multi-line batch exceeds the 100 line limit',
|
|
4409
|
+
),
|
|
4410
|
+
).toBe(true);
|
|
4411
|
+
|
|
4412
|
+
// The accumulator is gone: a normal PRIVMSG now delivers instead of
|
|
4413
|
+
// accumulating into the aborted batch.
|
|
4414
|
+
runtime.calls.length = 0;
|
|
4415
|
+
await actor.receiveTextFrame('PRIVMSG #foo :plain\r\n');
|
|
4416
|
+
expect(
|
|
4417
|
+
sentTo(runtime, 'c2').some((l) => l.text === ':alice!alice@example.com PRIVMSG #foo :plain'),
|
|
4418
|
+
).toBe(true);
|
|
4419
|
+
});
|
|
4420
|
+
|
|
4421
|
+
it('honours a configured multilineMaxEntries override', async () => {
|
|
4422
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4423
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline', 'standard-replies']);
|
|
4424
|
+
(actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
|
|
4425
|
+
...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
|
|
4426
|
+
multilineMaxEntries: 2,
|
|
4427
|
+
};
|
|
4428
|
+
await setupMultilineChannel(runtime, [
|
|
4429
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4430
|
+
]);
|
|
4431
|
+
runtime.calls.length = 0;
|
|
4432
|
+
|
|
4433
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4434
|
+
await actor.receiveTextFrame('PRIVMSG #foo :one\r\n');
|
|
4435
|
+
await actor.receiveTextFrame('PRIVMSG #foo :two\r\n');
|
|
4436
|
+
await actor.receiveTextFrame('PRIVMSG #foo :three\r\n');
|
|
4437
|
+
|
|
4438
|
+
const aliceLines = sentTo(runtime, 'c1');
|
|
4439
|
+
expect(aliceLines.some((l) => l.text === 'BATCH -m1')).toBe(true);
|
|
4440
|
+
expect(
|
|
4441
|
+
aliceLines.some(
|
|
4442
|
+
(l) =>
|
|
4443
|
+
l.text ===
|
|
4444
|
+
':irc.example.com FAIL BATCH MULTILINE_MAX_LINES #foo :Multi-line batch exceeds the 2 line limit',
|
|
4445
|
+
),
|
|
4446
|
+
).toBe(true);
|
|
4447
|
+
});
|
|
4448
|
+
|
|
4449
|
+
it('clears an abandoned open batch on connection teardown (dispose)', async () => {
|
|
4450
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4451
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4452
|
+
await setupMultilineChannel(runtime, [
|
|
4453
|
+
{ conn: 'c1', nick: 'alice', caps: ['batch', 'draft/multiline'] },
|
|
4454
|
+
{ conn: 'c2', nick: 'bob' },
|
|
4455
|
+
]);
|
|
4456
|
+
runtime.calls.length = 0;
|
|
4457
|
+
|
|
4458
|
+
await actor.receiveTextFrame('BATCH +m1 draft/multiline :#foo\r\n');
|
|
4459
|
+
await actor.receiveTextFrame('PRIVMSG #foo :partial\r\n');
|
|
4460
|
+
expect((actor as unknown as { openMultilineBatch?: unknown }).openMultilineBatch).toBeDefined();
|
|
4461
|
+
|
|
4462
|
+
actor.dispose();
|
|
4463
|
+
|
|
4464
|
+
expect(
|
|
4465
|
+
(actor as unknown as { openMultilineBatch?: unknown }).openMultilineBatch,
|
|
4466
|
+
).toBeUndefined();
|
|
4467
|
+
// Post-teardown traffic delivers normally — nothing leaks into a stale batch.
|
|
4468
|
+
await actor.receiveTextFrame('PRIVMSG #foo :after\r\n');
|
|
4469
|
+
expect(
|
|
4470
|
+
sentTo(runtime, 'c2').some((l) => l.text === ':alice!alice@example.com PRIVMSG #foo :after'),
|
|
4471
|
+
).toBe(true);
|
|
4472
|
+
});
|
|
4473
|
+
|
|
4474
|
+
it('dispose() is a safe no-op when no batch is open', () => {
|
|
4475
|
+
const { actor } = makeActor({});
|
|
4476
|
+
expect(() => actor.dispose()).not.toThrow();
|
|
4477
|
+
expect(
|
|
4478
|
+
(actor as unknown as { openMultilineBatch?: unknown }).openMultilineBatch,
|
|
4479
|
+
).toBeUndefined();
|
|
4480
|
+
});
|
|
4481
|
+
|
|
4482
|
+
it('nick-target close still rejects an over-budget batch (close-time guard survives the incremental check)', async () => {
|
|
4483
|
+
const { actor, runtime, conn } = makeActor({});
|
|
4484
|
+
conn.caps = new Set<string>(['batch', 'draft/multiline']);
|
|
4485
|
+
(actor as unknown as { serverConfig: ServerConfig }).serverConfig = {
|
|
4486
|
+
...(actor as unknown as { serverConfig: ServerConfig }).serverConfig,
|
|
4487
|
+
multilineMaxBytes: 5,
|
|
4488
|
+
};
|
|
4489
|
+
// The incremental accumulator aborts over-budget pushes before close, so
|
|
4490
|
+
// the close-time guard only fires when the budget shrinks under an open
|
|
4491
|
+
// batch (e.g. a REHASH between BATCH +id and BATCH -id). Inject the
|
|
4492
|
+
// over-budget batch directly and close it through the real route.
|
|
4493
|
+
(actor as unknown as { openMultilineBatch: unknown }).openMultilineBatch = {
|
|
4494
|
+
id: 'm1',
|
|
4495
|
+
target: 'bob',
|
|
4496
|
+
command: 'PRIVMSG',
|
|
4497
|
+
entries: [{ text: 'aaaaaaaaaa' }],
|
|
4498
|
+
};
|
|
4499
|
+
runtime.calls.length = 0;
|
|
4500
|
+
|
|
4501
|
+
await actor.receiveTextFrame('BATCH -m1\r\n');
|
|
4502
|
+
|
|
4503
|
+
expect(
|
|
4504
|
+
sentTo(runtime, 'c1').some(
|
|
4505
|
+
(l) =>
|
|
4506
|
+
l.text ===
|
|
4507
|
+
':irc.example.com FAIL BATCH MULTILINE_MAX_BYTES bob :Multi-line batch exceeds the 5 byte limit',
|
|
4508
|
+
),
|
|
4509
|
+
).toBe(true);
|
|
4510
|
+
});
|
|
4002
4511
|
});
|
|
4003
4512
|
|
|
4004
4513
|
// ---------------------------------------------------------------------------
|