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