serverless-ircd 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/deploy-cf-tcp.yml +26 -2
- package/.github/workflows/deploy-cf.yml +26 -0
- package/CHANGELOG.md +289 -0
- package/README.md +153 -20
- package/apps/aws-stack/bin/aws.ts +36 -0
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +221 -15
- package/apps/aws-stack/tests/stack.test.ts +450 -16
- package/apps/cf-tcp-container/Dockerfile +37 -5
- package/apps/cf-tcp-container/package.json +7 -2
- package/apps/cf-tcp-container/src/config-loader.ts +113 -2
- package/apps/cf-tcp-container/src/container-server.ts +256 -79
- package/apps/cf-tcp-container/src/main.ts +22 -7
- package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
- package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
- package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
- package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
- package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
- package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
- package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
- package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
- package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
- package/apps/cf-tcp-container/wrangler.toml +17 -4
- package/apps/cf-worker/package.json +2 -2
- package/apps/cf-worker/src/worker.ts +77 -5
- package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
- package/apps/cf-worker/tests/smoke.test.ts +4 -0
- package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
- package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
- package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
- package/apps/cf-worker/wrangler.test.toml +15 -1
- package/apps/cf-worker/wrangler.toml +86 -9
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +14 -2
- package/apps/local-cli/src/line-scanner.ts +26 -0
- package/apps/local-cli/src/server.ts +23 -2
- package/apps/local-cli/tests/line-scanner.test.ts +64 -0
- package/apps/local-cli/tests/tcp.test.ts +29 -0
- package/apps/web/package.json +1 -1
- package/docs/AWS-Deployment.md +123 -22
- package/docs/AWS-TCP-Deployment.md +37 -2
- package/docs/Chat-History.md +55 -0
- package/docs/Cloudflare-Deployment-Guide.md +9 -2
- package/docs/Cloudflare-TCP-Deployment.md +135 -52
- package/docs/SASL-EXTERNAL.md +175 -0
- package/package.json +3 -3
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/admission.ts +28 -13
- package/packages/aws-adapter/src/aws-runtime.ts +30 -3
- package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
- package/packages/aws-adapter/src/config-loader.ts +134 -6
- package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
- package/packages/aws-adapter/src/handlers/connect.ts +47 -1
- package/packages/aws-adapter/src/handlers/default.ts +95 -6
- package/packages/aws-adapter/src/handlers/index.ts +31 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
- package/packages/aws-adapter/src/ip-admission.ts +79 -0
- package/packages/aws-adapter/src/serialize.ts +8 -0
- package/packages/aws-adapter/src/tables.ts +9 -0
- package/packages/aws-adapter/tests/admission.test.ts +60 -2
- package/packages/aws-adapter/tests/aws-harness.ts +23 -1
- package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
- package/packages/aws-adapter/tests/connect.test.ts +199 -2
- package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
- package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
- package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
- package/packages/aws-adapter/tests/handlers.test.ts +57 -1
- package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
- package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
- package/packages/cf-adapter/package.json +1 -1
- package/packages/cf-adapter/src/cf-runtime.ts +48 -9
- package/packages/cf-adapter/src/config-loader.ts +133 -8
- package/packages/cf-adapter/src/connection-do.ts +154 -21
- package/packages/cf-adapter/src/counter-do.ts +142 -0
- package/packages/cf-adapter/src/d1-services-store.ts +47 -5
- package/packages/cf-adapter/src/env.ts +88 -0
- package/packages/cf-adapter/src/index.ts +17 -1
- package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
- package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
- package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
- package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
- package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
- package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
- package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
- package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
- package/packages/cf-adapter/tests/worker/main.ts +4 -0
- package/packages/cf-adapter/wrangler.test.toml +18 -1
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/caps/capabilities.ts +20 -10
- package/packages/irc-core/src/certfp.ts +178 -0
- package/packages/irc-core/src/commands/cap.ts +10 -2
- package/packages/irc-core/src/commands/chanserv.ts +117 -14
- package/packages/irc-core/src/commands/chathistory.ts +13 -5
- package/packages/irc-core/src/commands/hostserv.ts +84 -8
- package/packages/irc-core/src/commands/index.ts +2 -1
- package/packages/irc-core/src/commands/invite.ts +1 -7
- package/packages/irc-core/src/commands/join.ts +1 -16
- package/packages/irc-core/src/commands/kick.ts +1 -8
- package/packages/irc-core/src/commands/list.ts +1 -8
- package/packages/irc-core/src/commands/mode.ts +1 -8
- package/packages/irc-core/src/commands/multiline.ts +4 -10
- package/packages/irc-core/src/commands/names.ts +53 -13
- package/packages/irc-core/src/commands/nickserv.ts +40 -1
- package/packages/irc-core/src/commands/oper.ts +361 -8
- package/packages/irc-core/src/commands/part.ts +4 -10
- package/packages/irc-core/src/commands/privmsg.ts +8 -4
- package/packages/irc-core/src/commands/registration.ts +146 -2
- package/packages/irc-core/src/commands/sasl.ts +136 -19
- package/packages/irc-core/src/commands/topic.ts +10 -12
- package/packages/irc-core/src/commands/who.ts +1 -8
- package/packages/irc-core/src/config.ts +393 -20
- package/packages/irc-core/src/effects.ts +24 -0
- package/packages/irc-core/src/flood-control.ts +10 -10
- package/packages/irc-core/src/frame-rate-limit.ts +82 -0
- package/packages/irc-core/src/index.ts +8 -0
- package/packages/irc-core/src/oper-hashing.ts +43 -0
- package/packages/irc-core/src/oper-lockout.ts +87 -0
- package/packages/irc-core/src/ports.ts +395 -36
- package/packages/irc-core/src/protocol/bytes.ts +65 -0
- package/packages/irc-core/src/protocol/channel-name.ts +37 -0
- package/packages/irc-core/src/protocol/index.ts +12 -1
- package/packages/irc-core/src/protocol/outbound.ts +43 -10
- package/packages/irc-core/src/protocol/parser.ts +79 -10
- package/packages/irc-core/src/state/connection.ts +13 -0
- package/packages/irc-core/src/types.ts +228 -13
- package/packages/irc-core/src/ws-framing.ts +5 -4
- package/packages/irc-core/tests/bytes.test.ts +89 -0
- package/packages/irc-core/tests/certfp.test.ts +117 -0
- package/packages/irc-core/tests/commands/cap.test.ts +76 -2
- package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
- package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
- package/packages/irc-core/tests/commands/join.test.ts +78 -1
- package/packages/irc-core/tests/commands/names.test.ts +193 -0
- package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
- package/packages/irc-core/tests/commands/oper.test.ts +560 -2
- package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
- package/packages/irc-core/tests/commands/registration.test.ts +463 -1
- package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
- package/packages/irc-core/tests/commands/topic.test.ts +137 -2
- package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
- package/packages/irc-core/tests/config.test.ts +534 -2
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/flood-control.test.ts +29 -1
- package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
- package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
- package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
- package/packages/irc-core/tests/outbound.test.ts +148 -0
- package/packages/irc-core/tests/parser.test.ts +287 -5
- package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
- package/packages/irc-core/tests/ports.test.ts +99 -7
- package/packages/irc-core/tests/services-store.test.ts +376 -14
- package/packages/irc-core/tests/ws-framing.test.ts +45 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +123 -8
- package/packages/irc-server/src/dispatch.ts +1 -0
- package/packages/irc-server/src/index.ts +7 -0
- package/packages/irc-server/src/redact.ts +159 -0
- package/packages/irc-server/src/runtime.ts +14 -0
- package/packages/irc-server/src/transport.ts +28 -1
- package/packages/irc-server/tests/actor.test.ts +544 -7
- package/packages/irc-server/tests/dispatch.test.ts +31 -0
- package/packages/irc-server/tests/redact.test.ts +198 -0
- package/packages/irc-server/tests/runtime.test.ts +2 -0
- package/packages/irc-server/tests/transport.test.ts +66 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
- package/scripts/package.json +1 -1
- package/tools/ci-hardening/package.json +2 -2
- package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
- package/tools/ci-hardening/src/cf-deploy.ts +118 -0
- package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
- package/tools/ci-hardening/src/env-var-drift.ts +192 -0
- package/tools/ci-hardening/src/hostname-guard.ts +11 -0
- package/tools/ci-hardening/src/index.ts +17 -0
- package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
- package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
- package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
- package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
- package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
- package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
- package/tools/ci-hardening/vitest.config.ts +5 -1
- package/tools/hash-oper-cred.ts +85 -0
- package/tools/load-test/package.json +1 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Effect } from './effects.js';
|
|
10
10
|
import type { FloodControlConfig } from './flood-control.js';
|
|
11
|
+
import type { OperFailureStats } from './oper-lockout.js';
|
|
11
12
|
import type {
|
|
12
13
|
AwayStore,
|
|
13
14
|
Clock,
|
|
@@ -52,16 +53,67 @@ export interface StsConfig {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
56
|
+
* Legacy plaintext IRC operator credential (DEPRECATED — retained for a
|
|
57
|
+
* single deprecation-cycle window).
|
|
58
|
+
*
|
|
59
|
+
* The {@link operReducer} still verifies this shape via timing-safe byte
|
|
60
|
+
* comparison, but emits a one-shot `warn` per boot when it is encountered.
|
|
61
|
+
* Migrate to {@link OperCredHashed} via `tools/hash-oper-cred.ts`; this
|
|
62
|
+
* form will be removed in a future release.
|
|
59
63
|
*/
|
|
60
|
-
export interface
|
|
64
|
+
export interface OperCredPlaintext {
|
|
61
65
|
readonly user: string;
|
|
62
66
|
readonly password: string;
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
/**
|
|
70
|
+
* At-rest hashed IRC operator credential (the recommended shape).
|
|
71
|
+
*
|
|
72
|
+
* `salt` and `hash` are base64 strings produced by
|
|
73
|
+
* `tools/hash-oper-cred.ts` (which wraps {@link hashAccountCredential}'s
|
|
74
|
+
* scrypt derivation). The {@link operReducer} verifies via the shared
|
|
75
|
+
* {@link verifyHashedPassword} with `timingSafeEqual`, mirroring the
|
|
76
|
+
* NickServ / SASL PLAIN credential path.
|
|
77
|
+
*/
|
|
78
|
+
export interface OperCredHashed {
|
|
79
|
+
readonly user: string;
|
|
80
|
+
readonly salt: string;
|
|
81
|
+
readonly hash: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A single IRC operator credential. Either the at-rest hashed form
|
|
86
|
+
* ({@link OperCredHashed}, recommended) or the legacy plaintext form
|
|
87
|
+
* ({@link OperCredPlaintext}, deprecated — retained for a single
|
|
88
|
+
* deprecation-cycle window). The {@link operReducer} consults whichever
|
|
89
|
+
* shape is present; adapters are responsible for any hashing at the auth
|
|
90
|
+
* boundary if they layer one on top.
|
|
91
|
+
*/
|
|
92
|
+
export type OperCred = OperCredHashed | OperCredPlaintext;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Per-IP failed-`OPER` lockout tunables as seen by reducers. Mirrors the
|
|
96
|
+
* `oper` sub-schema of {@link ServerConfigSchema}; the OPER reducer reads
|
|
97
|
+
* `maxFailures` / `failureWindowSeconds` (falling back to the documented
|
|
98
|
+
* defaults when unset).
|
|
99
|
+
*
|
|
100
|
+
* Optional fields are widened to `T | undefined` to mirror Zod's
|
|
101
|
+
* `.optional()` output under `exactOptionalPropertyTypes: true`.
|
|
102
|
+
*/
|
|
103
|
+
export interface OperThrottleConfig {
|
|
104
|
+
/**
|
|
105
|
+
* Consecutive failed `OPER` attempts (per host, per window) that trip
|
|
106
|
+
* the lockout. Omitted → the reducer falls back to
|
|
107
|
+
* {@link DEFAULT_OPER_MAX_FAILURES} (3).
|
|
108
|
+
*/
|
|
109
|
+
readonly maxFailures?: number | undefined;
|
|
110
|
+
/**
|
|
111
|
+
* Sliding window length in seconds. Omitted → the reducer falls back to
|
|
112
|
+
* {@link DEFAULT_OPER_FAILURE_WINDOW_SECONDS} (300).
|
|
113
|
+
*/
|
|
114
|
+
readonly failureWindowSeconds?: number | undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
65
117
|
/**
|
|
66
118
|
* NickServ-specific tunables as seen by reducers. Mirrors the
|
|
67
119
|
* `nickServ` sub-schema of {@link ServerConfigSchema}; the NickServ
|
|
@@ -78,6 +130,84 @@ export interface NickServConfig {
|
|
|
78
130
|
* {@link DEFAULT_MIN_PASSWORD_LENGTH} (8).
|
|
79
131
|
*/
|
|
80
132
|
readonly minPasswordLength?: number | undefined;
|
|
133
|
+
/**
|
|
134
|
+
* Failed-IDENTIFY threshold that freezes the account. Omitted → the
|
|
135
|
+
* NickServ IDENTIFY reducer falls back to
|
|
136
|
+
* {@link DEFAULT_MAX_IDENTIFY_FAILURES} (5).
|
|
137
|
+
*/
|
|
138
|
+
readonly maxIdentifyFailures?: number | undefined;
|
|
139
|
+
/**
|
|
140
|
+
* Sliding window (seconds) the failed-IDENTIFY threshold counts
|
|
141
|
+
* within. Omitted → the reducer falls back to
|
|
142
|
+
* {@link DEFAULT_IDENTIFY_FREEZE_SECONDS} (300).
|
|
143
|
+
*/
|
|
144
|
+
readonly identifyFreezeSeconds?: number | undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* SASL brute-force hardening tunables as seen by reducers. Mirrors the
|
|
149
|
+
* `sasl` sub-schema of {@link ServerConfigSchema}; the SASL reducer reads
|
|
150
|
+
* them to trip a per-connection lockout after too many failed
|
|
151
|
+
* `AUTHENTICATE` attempts (falling back to the documented defaults when
|
|
152
|
+
* unset).
|
|
153
|
+
*
|
|
154
|
+
* Optional fields are widened to `T | undefined` to mirror Zod's
|
|
155
|
+
* `.optional()` output under `exactOptionalPropertyTypes: true`.
|
|
156
|
+
*/
|
|
157
|
+
export interface SaslConfig {
|
|
158
|
+
/**
|
|
159
|
+
* Failed `AUTHENTICATE` attempts (per connection, within the sliding
|
|
160
|
+
* window) before the connection is disconnected. Omitted → the reducer
|
|
161
|
+
* falls back to {@link DEFAULT_SASL_MAX_FAILURES} (3).
|
|
162
|
+
*/
|
|
163
|
+
readonly maxFailures?: number | undefined;
|
|
164
|
+
/**
|
|
165
|
+
* Sliding-window length (seconds) the failure tracker counts within.
|
|
166
|
+
* Failures older than the window no longer count. Omitted → the reducer
|
|
167
|
+
* falls back to {@link DEFAULT_SASL_FAILURE_WINDOW_SECONDS} (60).
|
|
168
|
+
*/
|
|
169
|
+
readonly failureWindowSeconds?: number | undefined;
|
|
170
|
+
/**
|
|
171
|
+
* Operator opt-in for the certificate-backed SASL EXTERNAL mechanism.
|
|
172
|
+
* Omitted → EXTERNAL is neither advertised nor accepted (the reducers
|
|
173
|
+
* treat absence as `false`). Enabling additionally requires a bound
|
|
174
|
+
* mTLS identity source and a secure (TLS) connection — see the
|
|
175
|
+
* AUTHENTICATE reducer. The adapters surface this as the
|
|
176
|
+
* `EXTERNAL_ENABLED` env var.
|
|
177
|
+
*/
|
|
178
|
+
readonly externalEnabled?: boolean | undefined;
|
|
179
|
+
/**
|
|
180
|
+
* Transport-level EXTERNAL non-support override. When set (by an
|
|
181
|
+
* adapter whose transport cannot surface a client-cert identity —
|
|
182
|
+
* e.g. the Spectrum PROXY-v1 TCP container origin), SASL EXTERNAL is
|
|
183
|
+
* hard-disabled for the deployment: the `sasl` cap and `908
|
|
184
|
+
* ERR_SASLMECHS` list `PLAIN` only, and `AUTHENTICATE EXTERNAL` is
|
|
185
|
+
* rejected up front with `904 ERR_SASLFAIL` carrying this exact
|
|
186
|
+
* message (a stable, transport-specific refusal) — even if
|
|
187
|
+
* {@link externalEnabled} and every three-way-gate input is bound.
|
|
188
|
+
* The rejection is a protocol-level refusal, not a credential
|
|
189
|
+
* attempt: it does not count toward the per-connection SASL failure
|
|
190
|
+
* lockout.
|
|
191
|
+
*/
|
|
192
|
+
readonly externalUnsupportedMessage?: string | undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* `draft/chathistory` tunables as seen by reducers. Mirrors the
|
|
197
|
+
* `chathistory` sub-schema of {@link ServerConfigSchema}; the CHATHISTORY
|
|
198
|
+
* reducer caps every client-supplied query limit at `maxLimit` (falling
|
|
199
|
+
* back to {@link DEFAULT_MAX_CHATHISTORY_LIMIT} when unset).
|
|
200
|
+
*
|
|
201
|
+
* Optional fields are widened to `T | undefined` to mirror Zod's
|
|
202
|
+
* `.optional()` output under `exactOptionalPropertyTypes: true`.
|
|
203
|
+
*/
|
|
204
|
+
export interface ChathistoryConfig {
|
|
205
|
+
/**
|
|
206
|
+
* Maximum number of messages one `CHATHISTORY` query may return.
|
|
207
|
+
* Omitted → the reducer falls back to
|
|
208
|
+
* {@link DEFAULT_MAX_CHATHISTORY_LIMIT} (100).
|
|
209
|
+
*/
|
|
210
|
+
readonly maxLimit?: number | undefined;
|
|
81
211
|
}
|
|
82
212
|
|
|
83
213
|
/**
|
|
@@ -131,6 +261,17 @@ export interface ServerConfig {
|
|
|
131
261
|
* here.
|
|
132
262
|
*/
|
|
133
263
|
readonly operCreds?: ReadonlyArray<OperCred> | undefined;
|
|
264
|
+
/**
|
|
265
|
+
* Per-IP failed-`OPER` lockout tunables consulted by the `OPER`
|
|
266
|
+
* reducer's brute-force gate. Omitted (or field-omitted) values fall
|
|
267
|
+
* back to the documented defaults (3 failures / 300s window); the
|
|
268
|
+
* parsed config always supplies both fields (schema defaults).
|
|
269
|
+
*
|
|
270
|
+
* Typed `OperThrottleConfig | undefined` to mirror Zod's `.optional()`
|
|
271
|
+
* output shape under `exactOptionalPropertyTypes: true` so inline
|
|
272
|
+
* `ServerConfig` literals in tests may omit it.
|
|
273
|
+
*/
|
|
274
|
+
readonly oper?: OperThrottleConfig | undefined;
|
|
134
275
|
maxChannelsPerUser: number;
|
|
135
276
|
maxTargetsPerCommand: number;
|
|
136
277
|
maxListEntries: number;
|
|
@@ -183,6 +324,18 @@ export interface ServerConfig {
|
|
|
183
324
|
* literals in tests may omit it.
|
|
184
325
|
*/
|
|
185
326
|
readonly multilineMaxBytes?: number | undefined;
|
|
327
|
+
/**
|
|
328
|
+
* Per-batch entry ceiling for IRCv3 `draft/multiline`. Enforced
|
|
329
|
+
* incrementally on each accumulated inner line — together with
|
|
330
|
+
* {@link ServerConfig.multilineMaxBytes} — so a client cannot hold an
|
|
331
|
+
* unbounded batch open. Defaults to
|
|
332
|
+
* {@link DEFAULT_MULTILINE_MAX_ENTRIES} (100) when omitted.
|
|
333
|
+
*
|
|
334
|
+
* Typed `number | undefined` to mirror Zod's optional output shape under
|
|
335
|
+
* `exactOptionalPropertyTypes: true` so inline `ServerConfig` literals in
|
|
336
|
+
* tests may omit it.
|
|
337
|
+
*/
|
|
338
|
+
readonly multilineMaxEntries?: number | undefined;
|
|
186
339
|
/**
|
|
187
340
|
* Per-connection MONITOR watchlist ceiling, advertised as `MONITOR=<n>`
|
|
188
341
|
* in `005 RPL_ISUPPORT`. Omitted → the reducer falls back to
|
|
@@ -240,18 +393,58 @@ export interface ServerConfig {
|
|
|
240
393
|
*/
|
|
241
394
|
readonly nickServ?: NickServConfig | undefined;
|
|
242
395
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
396
|
+
* SASL brute-force hardening tunables consulted by the SASL reducer:
|
|
397
|
+
* `maxFailures` failed `AUTHENTICATE` attempts within
|
|
398
|
+
* `failureWindowSeconds` disconnect the connection. Omitted entirely →
|
|
399
|
+
* the reducer falls back to the documented defaults (3 failures / 60 s).
|
|
400
|
+
*
|
|
401
|
+
* Typed `SaslConfig | undefined` to mirror Zod's `.optional()` output
|
|
402
|
+
* under `exactOptionalPropertyTypes: true` so inline `ServerConfig`
|
|
403
|
+
* literals in tests may omit it.
|
|
404
|
+
*/
|
|
405
|
+
readonly sasl?: SaslConfig | undefined;
|
|
406
|
+
/**
|
|
407
|
+
* `draft/chathistory` tunables consulted by the CHATHISTORY reducer.
|
|
408
|
+
* Every client-supplied query limit is silently capped at
|
|
409
|
+
* `maxLimit`; omitted entirely → the documented default ceiling (100).
|
|
410
|
+
*
|
|
411
|
+
* Typed `ChathistoryConfig | undefined` to mirror Zod's `.optional()`
|
|
412
|
+
* output shape under `exactOptionalPropertyTypes: true` so inline
|
|
413
|
+
* `ServerConfig` literals in tests may omit it.
|
|
414
|
+
*/
|
|
415
|
+
readonly chathistory?: ChathistoryConfig | undefined;
|
|
416
|
+
/**
|
|
417
|
+
* HostServ approval mode for `REQUEST <vhost>`. `false` (the parsed
|
|
418
|
+
* default) is oper-queue mode: `REQUEST` queues a pending request that an
|
|
419
|
+
* oper must `APPROVE` (or `REJECT`); `LIST` enumerates the pending
|
|
420
|
+
* queue — the recommended mode. `true` preserves the legacy
|
|
421
|
+
* auto-approve behaviour (the vhost is recorded immediately, no oper
|
|
422
|
+
* review), hard-constrained by {@link hostservAutoApproveAllowlist} and
|
|
423
|
+
* a built-in denylist (reserved labels + this server's own hostname).
|
|
424
|
+
*
|
|
425
|
+
* @deprecated Auto-approve is deprecated; queue mode (the default) is
|
|
426
|
+
* the long-term path and a future release removes the auto-approve
|
|
427
|
+
* arm. See `docs/Services.md`.
|
|
249
428
|
*
|
|
250
429
|
* Typed `boolean | undefined` so inline `ServerConfig` literals in tests
|
|
251
|
-
* may omit it (the reducer treats `undefined` as
|
|
252
|
-
*
|
|
430
|
+
* may omit it (the reducer treats `undefined` as queue mode, matching
|
|
431
|
+
* the parsed default).
|
|
253
432
|
*/
|
|
254
433
|
readonly hostservAutoApproveVhosts?: boolean | undefined;
|
|
434
|
+
/**
|
|
435
|
+
* Explicit allowlist constraining auto-approved vhosts when
|
|
436
|
+
* {@link hostservAutoApproveVhosts} is `true`: an entry is an exact
|
|
437
|
+
* hostname (`users.example.com`) or a wildcard suffix
|
|
438
|
+
* (`*.users.example.com`, any depth beneath it). Case-insensitive.
|
|
439
|
+
* Ignored in queue mode (opers vet requests). The parsed config always
|
|
440
|
+
* supplies an array (default `[]`), and config parsing refuses to boot
|
|
441
|
+
* auto-approve mode with an empty allowlist; the reducer additionally
|
|
442
|
+
* fails closed when the field is omitted from an inline literal.
|
|
443
|
+
*
|
|
444
|
+
* Typed `ReadonlyArray<string> | undefined` to mirror Zod's `.optional()`
|
|
445
|
+
* output under `exactOptionalPropertyTypes: true`.
|
|
446
|
+
*/
|
|
447
|
+
readonly hostservAutoApproveAllowlist?: ReadonlyArray<string> | undefined;
|
|
255
448
|
}
|
|
256
449
|
|
|
257
450
|
/** Per-invocation context handed to every reducer. */
|
|
@@ -317,6 +510,21 @@ export interface Ctx {
|
|
|
317
510
|
* `904` / silent no-op.
|
|
318
511
|
*/
|
|
319
512
|
readonly services?: ServicesStore;
|
|
513
|
+
/**
|
|
514
|
+
* Per-IP failed-`OPER` counter (absent when the deployment's runtime
|
|
515
|
+
* does not maintain one). When present, the OPER reducer counts
|
|
516
|
+
* credential failures per source host: once
|
|
517
|
+
* `serverConfig.oper.maxFailures` failures land within
|
|
518
|
+
* `serverConfig.oper.failureWindowSeconds`, further OPER attempts from
|
|
519
|
+
* that host are rejected with `491 ERR_NOOPERHOST` without consulting
|
|
520
|
+
* the credential store, and a `BroadcastOperNotice` effect notifies
|
|
521
|
+
* every oper. A successful OPER resets the host's counter. Omitted (→
|
|
522
|
+
* `ctx.operFailures` undefined) preserves the unlocked behaviour for
|
|
523
|
+
* runtimes that have not wired the shared tracker (e.g. per-connection
|
|
524
|
+
* Durable Object adapters): every attempt still reaches the credential
|
|
525
|
+
* store.
|
|
526
|
+
*/
|
|
527
|
+
readonly operFailures?: OperFailureStats;
|
|
320
528
|
/** The connection invoking the command. Reducers mutate this freely. */
|
|
321
529
|
readonly connection: ConnectionState;
|
|
322
530
|
/** Convenience: `connection.id`. */
|
|
@@ -358,6 +566,12 @@ export interface BuildCtxOptions {
|
|
|
358
566
|
history?: NickHistoryStore;
|
|
359
567
|
away?: AwayStore;
|
|
360
568
|
services?: ServicesStore;
|
|
569
|
+
/**
|
|
570
|
+
* Per-IP failed-`OPER` counter shared across every connection (see
|
|
571
|
+
* {@link Ctx.operFailures}). Omitted → the OPER brute-force gate is
|
|
572
|
+
* disabled for this actor.
|
|
573
|
+
*/
|
|
574
|
+
operFailures?: OperFailureStats;
|
|
361
575
|
connection: ConnectionState;
|
|
362
576
|
/**
|
|
363
577
|
* IRCv3 `labeled-response` label extracted from the inbound `+label` tag.
|
|
@@ -383,6 +597,7 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
|
|
|
383
597
|
...(opts.history !== undefined ? { history: opts.history } : {}),
|
|
384
598
|
...(opts.away !== undefined ? { away: opts.away } : {}),
|
|
385
599
|
...(opts.services !== undefined ? { services: opts.services } : {}),
|
|
600
|
+
...(opts.operFailures !== undefined ? { operFailures: opts.operFailures } : {}),
|
|
386
601
|
...(opts.label !== undefined ? { label: opts.label } : {}),
|
|
387
602
|
};
|
|
388
603
|
return ctx;
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* every WebSocket runtime (Node `ws`, Cloudflare Durable Objects, Lambda).
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { byteLength } from './protocol/bytes.js';
|
|
28
29
|
import type { WsSubprotocol } from './ws-subprotocol.js';
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -103,12 +104,12 @@ export function frameToLines(frame: string, mode: WsFrameMode): string[] {
|
|
|
103
104
|
/**
|
|
104
105
|
* Returns `true` when `frame` fits within the IRCv3 WebSocket 510-byte
|
|
105
106
|
* message budget, `false` when it exceeds it and MUST be rejected by the
|
|
106
|
-
* inbound path. Measured in UTF-
|
|
107
|
-
* {@link enforceLineLimit} and the input parser cap line length
|
|
108
|
-
* package
|
|
107
|
+
* inbound path. Measured in UTF-8 bytes (the wire encoding), consistent
|
|
108
|
+
* with how {@link enforceLineLimit} and the input parser cap line length
|
|
109
|
+
* across this package, so multi-byte content cannot double the budget.
|
|
109
110
|
*/
|
|
110
111
|
export function isWithinWsByteBudget(frame: string): boolean {
|
|
111
|
-
return frame
|
|
112
|
+
return byteLength(frame) <= MAX_WS_MESSAGE_BYTES;
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
/**
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { byteLength, truncateToByteBudget } from '../src/protocol/bytes';
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// byteLength — UTF-8 byte accounting for IRC wire budgets
|
|
6
|
+
//
|
|
7
|
+
// IRC line limits are byte budgets (RFC 1459 §2.3), but JavaScript strings
|
|
8
|
+
// expose UTF-16 code units via `.length`. These tests pin the semantics the
|
|
9
|
+
// line-budget seams rely on: one byte per ASCII char, 2–4 bytes per
|
|
10
|
+
// non-ASCII code point.
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
describe('byteLength', () => {
|
|
14
|
+
it('returns 0 for the empty string', () => {
|
|
15
|
+
expect(byteLength('')).toBe(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('counts one byte per ASCII character (matches .length)', () => {
|
|
19
|
+
expect(byteLength('NICK bob')).toBe('NICK bob'.length);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('counts 2 bytes for a 2-byte BMP character (é)', () => {
|
|
23
|
+
expect('é'.length).toBe(1);
|
|
24
|
+
expect(byteLength('é')).toBe(2);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns 1022 for 511 é characters — not the UTF-16 count of 511', () => {
|
|
28
|
+
const s = 'é'.repeat(511);
|
|
29
|
+
expect(s.length).toBe(511);
|
|
30
|
+
expect(byteLength(s)).toBe(1022);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('counts 4 bytes for an astral code point (surrogate pair)', () => {
|
|
34
|
+
const emoji = '😀';
|
|
35
|
+
expect(emoji.length).toBe(2);
|
|
36
|
+
expect(byteLength(emoji)).toBe(4);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('accounts for mixed ASCII and multi-byte content', () => {
|
|
40
|
+
expect(byteLength('aé😀')).toBe(1 + 2 + 4);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// truncateToByteBudget — byte-bounded right-truncation
|
|
46
|
+
//
|
|
47
|
+
// Line-budget seams must not only DETECT over-budget multi-byte lines, they
|
|
48
|
+
// must emit output that fits the byte budget. Code-unit slicing cannot do
|
|
49
|
+
// that (510 'é' units = 1020 bytes) and can split surrogate pairs.
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
describe('truncateToByteBudget', () => {
|
|
53
|
+
it('returns the input unchanged when it already fits the budget', () => {
|
|
54
|
+
expect(truncateToByteBudget('NICK bob', 510)).toBe('NICK bob');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('truncates ASCII input exactly like code-unit slicing', () => {
|
|
58
|
+
expect(truncateToByteBudget('x'.repeat(600), 510)).toBe('x'.repeat(510));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('truncates multi-byte input so the result fits the byte budget', () => {
|
|
62
|
+
const out = truncateToByteBudget('é'.repeat(600), 510);
|
|
63
|
+
expect(byteLength(out)).toBeLessThanOrEqual(510);
|
|
64
|
+
// 510 bytes of 'é' = 255 chars; the budget is fully used.
|
|
65
|
+
expect(out).toBe('é'.repeat(255));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('stops before the code point that would cross the budget', () => {
|
|
69
|
+
// Budget 511 with 'é' (2 bytes each): 255 chars = 510 bytes; the 256th
|
|
70
|
+
// 'é' would make 512, so it is dropped and one byte stays unused.
|
|
71
|
+
expect(truncateToByteBudget('é'.repeat(600), 511)).toBe('é'.repeat(255));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('never splits a surrogate pair', () => {
|
|
75
|
+
const out = truncateToByteBudget('😀'.repeat(400), 510);
|
|
76
|
+
// 4-byte emoji: floor(510/4) = 127 complete pairs = 508 bytes.
|
|
77
|
+
expect(out).toBe('😀'.repeat(127));
|
|
78
|
+
expect(byteLength(out)).toBe(508);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('accounts for mixed ASCII and multi-byte content', () => {
|
|
82
|
+
// 'ab' (2 bytes) + 3 'é' (6 bytes) = 8 bytes; budget 5 keeps 'abé'.
|
|
83
|
+
expect(truncateToByteBudget('abééé', 5)).toBe('abé');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns the empty string for a zero budget', () => {
|
|
87
|
+
expect(truncateToByteBudget('abc', 0)).toBe('');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
CERT_FP_PREFIX,
|
|
4
|
+
canonicalizeCertSubject,
|
|
5
|
+
certFingerprintId,
|
|
6
|
+
certIdentityFromSubject,
|
|
7
|
+
} from '../src/certfp';
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// canonicalizeCertSubject — RFC 4514-style DN canonicalisation
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
describe('canonicalizeCertSubject', () => {
|
|
14
|
+
it('lowercases the attribute type but preserves the value case', () => {
|
|
15
|
+
expect(canonicalizeCertSubject('CN=Alice')).toBe('cn=Alice');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('collapses repeated whitespace inside a value', () => {
|
|
19
|
+
expect(canonicalizeCertSubject('CN=alice smith')).toBe('cn=alice smith');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('trims whitespace around the type and value', () => {
|
|
23
|
+
expect(canonicalizeCertSubject(' CN = alice ')).toBe('cn=alice');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('sorts RDNs into a stable order regardless of input ordering', () => {
|
|
27
|
+
expect(canonicalizeCertSubject('O=Example Inc,CN=alice')).toBe('cn=alice,o=Example Inc');
|
|
28
|
+
expect(canonicalizeCertSubject('CN=alice,O=Example Inc')).toBe('cn=alice,o=Example Inc');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('sorts AVAs inside a multi-valued RDN regardless of input ordering', () => {
|
|
32
|
+
expect(canonicalizeCertSubject('O=Example+CN=alice')).toBe('cn=alice+o=Example');
|
|
33
|
+
expect(canonicalizeCertSubject('CN=alice+O=Example')).toBe('cn=alice+o=Example');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('collapses two semantically-equal DNs (spacing + ordering differ) to one form', () => {
|
|
37
|
+
const a = canonicalizeCertSubject('CN=alice , O = Example Inc , C=US');
|
|
38
|
+
const b = canonicalizeCertSubject('C=US,O=Example Inc,CN=alice');
|
|
39
|
+
expect(a).toBe(b);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('does not split on an escaped separator', () => {
|
|
43
|
+
expect(canonicalizeCertSubject('CN=a\\,b,O=x')).toBe('cn=a\\,b,o=x');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('skips escaped characters when locating the type/value split', () => {
|
|
47
|
+
expect(canonicalizeCertSubject('CN\\=tag=y')).toBe('cn\\=tag=y');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('does not trim an escaped trailing space from a value', () => {
|
|
51
|
+
expect(canonicalizeCertSubject('CN=alice\\ ')).toBe('cn=alice\\ ');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('keeps an escape pair intact when a pending space precedes it', () => {
|
|
55
|
+
expect(canonicalizeCertSubject('CN=a \\, b')).toBe('cn=a \\, b');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('keeps a trailing dangling backslash verbatim', () => {
|
|
59
|
+
expect(canonicalizeCertSubject('CN=a\\')).toBe('cn=a\\');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('keeps a malformed AVA without an equals sign as trimmed collapsed text', () => {
|
|
63
|
+
expect(canonicalizeCertSubject(' alice smith ')).toBe('alice smith');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('canonicalises an empty DN to the empty string', () => {
|
|
67
|
+
expect(canonicalizeCertSubject('')).toBe('');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// certFingerprintId — normalised DER SHA-256 fingerprint binding id
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
describe('certFingerprintId', () => {
|
|
76
|
+
it('prefixes the fingerprint with the binding prefix', () => {
|
|
77
|
+
expect(certFingerprintId('af363dc8')).toBe(`${CERT_FP_PREFIX}af363dc8`);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('lowercases an uppercase hex fingerprint', () => {
|
|
81
|
+
expect(certFingerprintId('AF363DC8')).toBe('fp:af363dc8');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('strips openssl-style colon separators', () => {
|
|
85
|
+
expect(certFingerprintId('AF:36:3D:C8')).toBe('fp:af363dc8');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('trims surrounding whitespace', () => {
|
|
89
|
+
expect(certFingerprintId(' af363dc8 ')).toBe('fp:af363dc8');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// certIdentityFromSubject — CertIdentity construction
|
|
95
|
+
// ============================================================================
|
|
96
|
+
|
|
97
|
+
describe('certIdentityFromSubject', () => {
|
|
98
|
+
it('builds an identity with the canonical DN and no fingerprint key', () => {
|
|
99
|
+
expect(certIdentityFromSubject('CN=alice')).toEqual({
|
|
100
|
+
subject: 'CN=alice',
|
|
101
|
+
canonicalDn: 'cn=alice',
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('includes the fingerprint when the platform supplied one', () => {
|
|
106
|
+
expect(certIdentityFromSubject('CN=alice', 'af363dc8')).toEqual({
|
|
107
|
+
subject: 'CN=alice',
|
|
108
|
+
canonicalDn: 'cn=alice',
|
|
109
|
+
fingerprint: 'af363dc8',
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('carries the canonical DN of a multi-RDN subject', () => {
|
|
114
|
+
const id = certIdentityFromSubject('O=Example,CN=alice');
|
|
115
|
+
expect(id.canonicalDn).toBe('cn=alice,o=Example');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { getLsString } from '../../src/caps/capabilities';
|
|
3
|
+
import { certIdentityFromSubject } from '../../src/certfp';
|
|
3
4
|
import { capReducer } from '../../src/commands/cap';
|
|
4
5
|
import { userReducer } from '../../src/commands/registration';
|
|
5
6
|
import { Effect } from '../../src/effects';
|
|
@@ -131,8 +132,12 @@ describe('capReducer — LS', () => {
|
|
|
131
132
|
|
|
132
133
|
it('advertises sasl=PLAIN,EXTERNAL when an mTLS provider is configured', () => {
|
|
133
134
|
const state = makeState();
|
|
134
|
-
|
|
135
|
-
const
|
|
135
|
+
state.secure = true;
|
|
136
|
+
const provider: MtlsIdentityProvider = {
|
|
137
|
+
getIdentity: () => certIdentityFromSubject('CN=alice'),
|
|
138
|
+
};
|
|
139
|
+
const cfg: ServerConfig = { ...serverConfig, sasl: { externalEnabled: true } };
|
|
140
|
+
const ctx = makeCtx(state, cfg, provider);
|
|
136
141
|
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
137
142
|
const send = out.effects[0];
|
|
138
143
|
expect(send).toBeDefined();
|
|
@@ -142,6 +147,75 @@ describe('capReducer — LS', () => {
|
|
|
142
147
|
}
|
|
143
148
|
});
|
|
144
149
|
|
|
150
|
+
it('advertises sasl=PLAIN when EXTERNAL is not opted in, even with mTLS on a secure connection', () => {
|
|
151
|
+
// The operator opt-in gate: without sasl.externalEnabled the mechanism
|
|
152
|
+
// is not advertised, so clients never attempt an exchange the server
|
|
153
|
+
// would refuse with 908.
|
|
154
|
+
const state = makeState();
|
|
155
|
+
state.secure = true;
|
|
156
|
+
const provider: MtlsIdentityProvider = {
|
|
157
|
+
getIdentity: () => certIdentityFromSubject('CN=alice'),
|
|
158
|
+
};
|
|
159
|
+
const ctx = makeCtx(state, serverConfig, provider);
|
|
160
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
161
|
+
const send = out.effects[0];
|
|
162
|
+
expect(send).toBeDefined();
|
|
163
|
+
if (send?.tag === 'Send') {
|
|
164
|
+
const body = send.lines[0]?.text ?? '';
|
|
165
|
+
expect(body).toContain('sasl=PLAIN');
|
|
166
|
+
expect(body).not.toContain('sasl=PLAIN,EXTERNAL');
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('advertises sasl=PLAIN on an insecure connection even when EXTERNAL is opted in', () => {
|
|
171
|
+
// SASL EXTERNAL is certificate-backed authentication: the client-cert
|
|
172
|
+
// identity only carries meaning over a transport the server trusts
|
|
173
|
+
// (TLS). A plaintext connection never sees the mechanism.
|
|
174
|
+
const state = makeState();
|
|
175
|
+
state.secure = false;
|
|
176
|
+
const provider: MtlsIdentityProvider = {
|
|
177
|
+
getIdentity: () => certIdentityFromSubject('CN=alice'),
|
|
178
|
+
};
|
|
179
|
+
const cfg: ServerConfig = { ...serverConfig, sasl: { externalEnabled: true } };
|
|
180
|
+
const ctx = makeCtx(state, cfg, provider);
|
|
181
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
182
|
+
const send = out.effects[0];
|
|
183
|
+
expect(send).toBeDefined();
|
|
184
|
+
if (send?.tag === 'Send') {
|
|
185
|
+
const body = send.lines[0]?.text ?? '';
|
|
186
|
+
expect(body).toContain('sasl=PLAIN');
|
|
187
|
+
expect(body).not.toContain('sasl=PLAIN,EXTERNAL');
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('advertises sasl=PLAIN when the transport-level EXTERNAL override is set, even with mTLS + opt-in on a secure connection', () => {
|
|
192
|
+
// A transport that cannot surface a client-cert identity (the
|
|
193
|
+
// Spectrum TCP container origin) pins the mechanism off via
|
|
194
|
+
// sasl.externalUnsupportedMessage: the cap must never advertise a
|
|
195
|
+
// mechanism AUTHENTICATE would refuse with a transport-specific 904.
|
|
196
|
+
const state = makeState();
|
|
197
|
+
state.secure = true;
|
|
198
|
+
const provider: MtlsIdentityProvider = {
|
|
199
|
+
getIdentity: () => certIdentityFromSubject('CN=alice'),
|
|
200
|
+
};
|
|
201
|
+
const cfg: ServerConfig = {
|
|
202
|
+
...serverConfig,
|
|
203
|
+
sasl: {
|
|
204
|
+
externalEnabled: true,
|
|
205
|
+
externalUnsupportedMessage: 'SASL EXTERNAL not supported on this transport',
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
const ctx = makeCtx(state, cfg, provider);
|
|
209
|
+
const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
|
|
210
|
+
const send = out.effects[0];
|
|
211
|
+
expect(send).toBeDefined();
|
|
212
|
+
if (send?.tag === 'Send') {
|
|
213
|
+
const body = send.lines[0]?.text ?? '';
|
|
214
|
+
expect(body).toContain('sasl=PLAIN');
|
|
215
|
+
expect(body).not.toContain('sasl=PLAIN,EXTERNAL');
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
145
219
|
it('advertises draft/multiline=4096 with the default byte budget', () => {
|
|
146
220
|
const state = makeState();
|
|
147
221
|
const ctx = makeCtx(state);
|