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
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getLsString } from '../../src/caps/capabilities';
|
|
3
|
+
import { type CertIdentity, certIdentityFromSubject } from '../../src/certfp';
|
|
3
4
|
import { capReducer } from '../../src/commands/cap';
|
|
4
5
|
import { authenticateReducer } from '../../src/commands/sasl';
|
|
6
|
+
import type { HashedAccountCredential } from '../../src/credential-hashing';
|
|
5
7
|
import { Effect } from '../../src/effects';
|
|
6
8
|
import type { Effect as EffectType, RawLine } from '../../src/effects';
|
|
7
9
|
import type { AwayStore, MtlsIdentityProvider, ServicesStore } from '../../src/ports';
|
|
@@ -11,12 +13,16 @@ import {
|
|
|
11
13
|
InMemoryAwayStore,
|
|
12
14
|
InMemoryServicesStore,
|
|
13
15
|
SequentialIdFactory,
|
|
16
|
+
resetVerifyNickTimingSpy,
|
|
17
|
+
setVerifyNickHashedPassword,
|
|
14
18
|
} from '../../src/ports';
|
|
15
19
|
import { encodeBase64 } from '../../src/protocol/base64';
|
|
16
20
|
import type { IrcMessage } from '../../src/protocol/messages';
|
|
17
21
|
import { type ConnectionState, createConnection } from '../../src/state/connection';
|
|
18
22
|
import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
|
|
19
23
|
|
|
24
|
+
type VerifyHashedPasswordFn = (password: string, entry: HashedAccountCredential) => boolean;
|
|
25
|
+
|
|
20
26
|
const serverConfig: ServerConfig = {
|
|
21
27
|
serverName: 'irc.example.com',
|
|
22
28
|
networkName: 'ExampleNet',
|
|
@@ -29,6 +35,17 @@ const serverConfig: ServerConfig = {
|
|
|
29
35
|
quitMessage: 'Client Quit',
|
|
30
36
|
};
|
|
31
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Config with SASL EXTERNAL opted in — mirrors an operator setting the
|
|
40
|
+
* adapters' `EXTERNAL_ENABLED` env var. The mTLS-backed EXTERNAL tests
|
|
41
|
+
* bind this so they exercise the enabled path; the opt-in/secure-connection
|
|
42
|
+
* gate tests build their own configs explicitly.
|
|
43
|
+
*/
|
|
44
|
+
const externalServerConfig: ServerConfig = {
|
|
45
|
+
...serverConfig,
|
|
46
|
+
sasl: { externalEnabled: true },
|
|
47
|
+
};
|
|
48
|
+
|
|
32
49
|
const L = (text: string): RawLine => ({ text });
|
|
33
50
|
|
|
34
51
|
const SV = 'irc.example.com';
|
|
@@ -43,6 +60,10 @@ function saslReadyState(): ConnectionState {
|
|
|
43
60
|
s.registration = 'registering';
|
|
44
61
|
s.capNegotiating = true;
|
|
45
62
|
s.caps.add('sasl');
|
|
63
|
+
// SASL EXTERNAL requires a secure (TLS) transport; the standard helper
|
|
64
|
+
// models the common secure client so the EXTERNAL describe inherits it.
|
|
65
|
+
// The insecure-connection gate has its own explicit test.
|
|
66
|
+
s.secure = true;
|
|
46
67
|
return s;
|
|
47
68
|
}
|
|
48
69
|
|
|
@@ -67,7 +88,11 @@ function makeCtx(
|
|
|
67
88
|
away?: AwayStore,
|
|
68
89
|
): Ctx {
|
|
69
90
|
return buildCtx({
|
|
70
|
-
|
|
91
|
+
// A test that binds an mTLS provider intends to exercise the EXTERNAL
|
|
92
|
+
// path, so it gets the EXTERNAL-enabled config (mirrors an operator
|
|
93
|
+
// setting EXTERNAL_ENABLED). The gate matrix tests below build their
|
|
94
|
+
// own ctxs to control the flag explicitly.
|
|
95
|
+
serverConfig: mtlsIdentity !== undefined ? externalServerConfig : serverConfig,
|
|
71
96
|
clock: new FakeClock(1_000),
|
|
72
97
|
ids: new SequentialIdFactory(),
|
|
73
98
|
motd: EmptyMotdProvider,
|
|
@@ -83,11 +108,14 @@ class FakeMtlsIdentityProvider implements MtlsIdentityProvider {
|
|
|
83
108
|
readonly calls: string[] = [];
|
|
84
109
|
constructor(
|
|
85
110
|
private readonly connId: string,
|
|
86
|
-
private readonly
|
|
111
|
+
private readonly subject: string | undefined,
|
|
112
|
+
private readonly fingerprint?: string,
|
|
87
113
|
) {}
|
|
88
|
-
getIdentity(connId: string):
|
|
114
|
+
getIdentity(connId: string): CertIdentity | undefined {
|
|
89
115
|
this.calls.push(connId);
|
|
90
|
-
return connId === this.connId
|
|
116
|
+
return connId === this.connId && this.subject !== undefined
|
|
117
|
+
? certIdentityFromSubject(this.subject, this.fingerprint)
|
|
118
|
+
: undefined;
|
|
91
119
|
}
|
|
92
120
|
}
|
|
93
121
|
|
|
@@ -465,6 +493,57 @@ describe('authenticateReducer — PLAIN failure', () => {
|
|
|
465
493
|
});
|
|
466
494
|
});
|
|
467
495
|
|
|
496
|
+
// ============================================================================
|
|
497
|
+
// AUTHENTICATE PLAIN — timing equalisation (username-enumeration defence).
|
|
498
|
+
// The unknown-username and wrong-password paths must perform the SAME
|
|
499
|
+
// number of scrypt verifies (one each) and emit the identical 904 line, so
|
|
500
|
+
// a remote attacker cannot distinguish "user exists, wrong password" from
|
|
501
|
+
// "user unknown" by measuring latency between AUTHENTICATE and 904.
|
|
502
|
+
// ============================================================================
|
|
503
|
+
|
|
504
|
+
describe('authenticateReducer — PLAIN timing equalisation (username enumeration)', () => {
|
|
505
|
+
afterEach(() => {
|
|
506
|
+
resetVerifyNickTimingSpy();
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
function runPlainFailure(
|
|
510
|
+
authcid: string,
|
|
511
|
+
password: string,
|
|
512
|
+
): { verifyCalls: number; line: string } {
|
|
513
|
+
const spy = vi.fn<VerifyHashedPasswordFn>(() => false);
|
|
514
|
+
setVerifyNickHashedPassword(spy);
|
|
515
|
+
let out: { verifyCalls: number; line: string };
|
|
516
|
+
try {
|
|
517
|
+
const state = saslReadyState();
|
|
518
|
+
const ctx = makeCtx(state, registeredServices('alice', 'secret'));
|
|
519
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
520
|
+
const result = authenticateReducer(state, authenticate(plainPayload(authcid, password)), ctx);
|
|
521
|
+
out = { verifyCalls: spy.mock.calls.length, line: firstLineText(result.effects[0]) };
|
|
522
|
+
} finally {
|
|
523
|
+
setVerifyNickHashedPassword(null);
|
|
524
|
+
}
|
|
525
|
+
return out;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
it('runs exactly one scrypt verify on the unknown-username path (no O(1) miss)', () => {
|
|
529
|
+
const { verifyCalls } = runPlainFailure('definitely-not-a-user', 'secret');
|
|
530
|
+
expect(verifyCalls).toBe(1);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('runs exactly one scrypt verify on the wrong-password path', () => {
|
|
534
|
+
const { verifyCalls } = runPlainFailure('alice', 'wrong');
|
|
535
|
+
expect(verifyCalls).toBe(1);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('emits the identical 904 ERR_SASLFAIL on both paths with equal verify cost', () => {
|
|
539
|
+
const unknownUser = runPlainFailure('definitely-not-a-user', 'secret');
|
|
540
|
+
const wrongPassword = runPlainFailure('alice', 'wrong');
|
|
541
|
+
expect(unknownUser.verifyCalls).toBe(wrongPassword.verifyCalls);
|
|
542
|
+
expect(unknownUser.line).toBe(`:${SV} 904 alice :SASL authentication failed`);
|
|
543
|
+
expect(wrongPassword.line).toBe(unknownUser.line);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
468
547
|
// ============================================================================
|
|
469
548
|
// AUTHENTICATE PLAIN — no ServicesStore wired
|
|
470
549
|
// ============================================================================
|
|
@@ -526,7 +605,7 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
|
|
|
526
605
|
]);
|
|
527
606
|
});
|
|
528
607
|
|
|
529
|
-
it('forwards the resolved mTLS
|
|
608
|
+
it('forwards the resolved mTLS identity to services.verifyCertFP', () => {
|
|
530
609
|
const state = saslReadyState();
|
|
531
610
|
const services = registeredServices('alice', 'secret');
|
|
532
611
|
services.addCertFP('alice', 'CN=alice');
|
|
@@ -537,7 +616,41 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
|
|
|
537
616
|
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
538
617
|
authenticateReducer(state, authenticate('+'), ctx);
|
|
539
618
|
|
|
540
|
-
expect(verifyCertFP).toHaveBeenCalledWith('CN=alice');
|
|
619
|
+
expect(verifyCertFP).toHaveBeenCalledWith(certIdentityFromSubject('CN=alice'));
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('forwards the platform fingerprint on the identity to verifyCertFP', () => {
|
|
623
|
+
const state = saslReadyState();
|
|
624
|
+
const services = registeredServices('alice', 'secret');
|
|
625
|
+
services.addCertFingerprint('alice', 'aa112233');
|
|
626
|
+
const verifyCertFP = vi.spyOn(services, 'verifyCertFP');
|
|
627
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice', 'aa112233');
|
|
628
|
+
const ctx = makeCtx(state, services, provider);
|
|
629
|
+
|
|
630
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
631
|
+
authenticateReducer(state, authenticate('+'), ctx);
|
|
632
|
+
|
|
633
|
+
expect(verifyCertFP).toHaveBeenCalledWith(
|
|
634
|
+
expect.objectContaining({ subject: 'CN=alice', fingerprint: 'aa112233' }),
|
|
635
|
+
);
|
|
636
|
+
expect(state.account).toBe('alice');
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it('succeeds via an equivalent DN spelling (canonical compare)', () => {
|
|
640
|
+
const state = saslReadyState();
|
|
641
|
+
const services = registeredServices('alice', 'secret');
|
|
642
|
+
services.addCertFP('alice', 'CN=alice , O = Example Inc');
|
|
643
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'O=Example Inc,CN=alice');
|
|
644
|
+
const ctx = makeCtx(state, services, provider);
|
|
645
|
+
|
|
646
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
647
|
+
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
648
|
+
|
|
649
|
+
expect(state.account).toBe('alice');
|
|
650
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
651
|
+
L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),
|
|
652
|
+
L(`:${SV} 903 alice :SASL authentication successful`),
|
|
653
|
+
]);
|
|
541
654
|
});
|
|
542
655
|
|
|
543
656
|
it('clears saslMech/saslBuffer after a successful EXTERNAL', () => {
|
|
@@ -635,6 +748,223 @@ describe('authenticateReducer — EXTERNAL with mTLS', () => {
|
|
|
635
748
|
});
|
|
636
749
|
});
|
|
637
750
|
|
|
751
|
+
// ============================================================================
|
|
752
|
+
// AUTHENTICATE EXTERNAL — operator opt-in + secure-connection gate
|
|
753
|
+
// ============================================================================
|
|
754
|
+
|
|
755
|
+
describe('authenticateReducer — EXTERNAL opt-in / secure-connection gate', () => {
|
|
756
|
+
/** Builds a ctx with full control over the sasl config + transport. */
|
|
757
|
+
function gateCtx(
|
|
758
|
+
state: ConnectionState,
|
|
759
|
+
opts: { externalEnabled: boolean; provider?: MtlsIdentityProvider },
|
|
760
|
+
): Ctx {
|
|
761
|
+
return buildCtx({
|
|
762
|
+
serverConfig: { ...serverConfig, sasl: { externalEnabled: opts.externalEnabled } },
|
|
763
|
+
clock: new FakeClock(1_000),
|
|
764
|
+
ids: new SequentialIdFactory(),
|
|
765
|
+
motd: EmptyMotdProvider,
|
|
766
|
+
connection: state,
|
|
767
|
+
...(opts.provider !== undefined ? { mtlsIdentity: opts.provider } : {}),
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
it('refuses EXTERNAL with 908 when the flag is unset, even with mTLS on a secure connection', () => {
|
|
772
|
+
const state = saslReadyState();
|
|
773
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
774
|
+
const ctx = gateCtx(state, { externalEnabled: false, provider });
|
|
775
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
776
|
+
|
|
777
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
778
|
+
L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
|
|
779
|
+
]);
|
|
780
|
+
expect(state.saslMech).toBeUndefined();
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it('refuses EXTERNAL with 908 when the sasl block omits the flag entirely', () => {
|
|
784
|
+
// A deployment may set other sasl knobs (lockout tuning) without the
|
|
785
|
+
// EXTERNAL opt-in; the flag defaults off — absence is not consent.
|
|
786
|
+
const state = saslReadyState();
|
|
787
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
788
|
+
const ctx = buildCtx({
|
|
789
|
+
serverConfig: { ...serverConfig, sasl: { maxFailures: 3 } },
|
|
790
|
+
clock: new FakeClock(1_000),
|
|
791
|
+
ids: new SequentialIdFactory(),
|
|
792
|
+
motd: EmptyMotdProvider,
|
|
793
|
+
connection: state,
|
|
794
|
+
mtlsIdentity: provider,
|
|
795
|
+
});
|
|
796
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
797
|
+
|
|
798
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
799
|
+
L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
|
|
800
|
+
]);
|
|
801
|
+
expect(state.saslMech).toBeUndefined();
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('refuses EXTERNAL with 908 on an insecure connection even when the flag is enabled', () => {
|
|
805
|
+
const state = saslReadyState();
|
|
806
|
+
state.secure = false;
|
|
807
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
808
|
+
const ctx = gateCtx(state, { externalEnabled: true, provider });
|
|
809
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
810
|
+
|
|
811
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
812
|
+
L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
|
|
813
|
+
]);
|
|
814
|
+
expect(state.saslMech).toBeUndefined();
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it('does not count the 908 opt-in / secure-connection refusal toward the lockout', () => {
|
|
818
|
+
// A protocol-level mechanism rejection is not a credential attempt;
|
|
819
|
+
// an attacker probing EXTERNAL on an insecure transport must not be
|
|
820
|
+
// able to burn the failure budget (nor succeed by accident).
|
|
821
|
+
const state = saslReadyState();
|
|
822
|
+
state.secure = false;
|
|
823
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
824
|
+
const ctx = gateCtx(state, { externalEnabled: true, provider });
|
|
825
|
+
|
|
826
|
+
for (let i = 0; i < 3; i++) {
|
|
827
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
828
|
+
expect(firstLineText(out.effects[0])).toContain(' 908 ');
|
|
829
|
+
}
|
|
830
|
+
expect(state.saslFailures).toBeUndefined();
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('enters the payload phase when the flag is enabled on a secure connection with mTLS', () => {
|
|
834
|
+
const state = saslReadyState();
|
|
835
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=alice');
|
|
836
|
+
const ctx = gateCtx(state, { externalEnabled: true, provider });
|
|
837
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
838
|
+
|
|
839
|
+
expect(state.saslMech).toBe('EXTERNAL');
|
|
840
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([L('AUTHENTICATE +')]);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it('succeeds (900/903) with a bound cert on a secure, EXTERNAL-enabled connection', () => {
|
|
844
|
+
const state = saslReadyState();
|
|
845
|
+
const services = registeredServices('alice', 'secret');
|
|
846
|
+
services.addCertFingerprint('alice', 'aa112233445566778899aabbccddeeff');
|
|
847
|
+
const provider = new FakeMtlsIdentityProvider(
|
|
848
|
+
'c1',
|
|
849
|
+
'CN=alice',
|
|
850
|
+
'AA:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF',
|
|
851
|
+
);
|
|
852
|
+
const ctx = buildCtx({
|
|
853
|
+
serverConfig: externalServerConfig,
|
|
854
|
+
clock: new FakeClock(1_000),
|
|
855
|
+
ids: new SequentialIdFactory(),
|
|
856
|
+
motd: EmptyMotdProvider,
|
|
857
|
+
connection: state,
|
|
858
|
+
services,
|
|
859
|
+
mtlsIdentity: provider,
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
863
|
+
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
864
|
+
|
|
865
|
+
expect(state.account).toBe('alice');
|
|
866
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
867
|
+
L(`:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`),
|
|
868
|
+
L(`:${SV} 903 alice :SASL authentication successful`),
|
|
869
|
+
]);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it('fails with 904 on a secure, EXTERNAL-enabled connection when no binding matches the cert', () => {
|
|
873
|
+
const state = saslReadyState();
|
|
874
|
+
const services = registeredServices('alice', 'secret');
|
|
875
|
+
// alice has no cert binding; the presented cert is unknown to the store.
|
|
876
|
+
const provider = new FakeMtlsIdentityProvider('c1', 'CN=intruder');
|
|
877
|
+
const ctx = buildCtx({
|
|
878
|
+
serverConfig: externalServerConfig,
|
|
879
|
+
clock: new FakeClock(1_000),
|
|
880
|
+
ids: new SequentialIdFactory(),
|
|
881
|
+
motd: EmptyMotdProvider,
|
|
882
|
+
connection: state,
|
|
883
|
+
services,
|
|
884
|
+
mtlsIdentity: provider,
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
888
|
+
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
889
|
+
|
|
890
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
891
|
+
L(`:${SV} 904 alice :SASL authentication failed`),
|
|
892
|
+
]);
|
|
893
|
+
expect(state.account).toBeUndefined();
|
|
894
|
+
expect(state.saslFailures).toEqual([1_000]);
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// ============================================================================
|
|
899
|
+
// AUTHENTICATE EXTERNAL — transport-level non-support override
|
|
900
|
+
// ============================================================================
|
|
901
|
+
|
|
902
|
+
describe('authenticateReducer — EXTERNAL transport-level non-support override', () => {
|
|
903
|
+
/**
|
|
904
|
+
* Builds a ctx carrying the transport-level EXTERNAL non-support
|
|
905
|
+
* override — the seam a transport with no mTLS identity source (the
|
|
906
|
+
* Spectrum TCP container origin) uses to reject
|
|
907
|
+
* `AUTHENTICATE EXTERNAL` with an explicit, stable `904` instead of
|
|
908
|
+
* the generic three-way-gate `908`.
|
|
909
|
+
*/
|
|
910
|
+
function unsupportedCtx(state: ConnectionState): Ctx {
|
|
911
|
+
return buildCtx({
|
|
912
|
+
serverConfig: {
|
|
913
|
+
...serverConfig,
|
|
914
|
+
sasl: {
|
|
915
|
+
externalEnabled: true,
|
|
916
|
+
externalUnsupportedMessage: 'SASL EXTERNAL not supported on this transport',
|
|
917
|
+
},
|
|
918
|
+
},
|
|
919
|
+
clock: new FakeClock(1_000),
|
|
920
|
+
ids: new SequentialIdFactory(),
|
|
921
|
+
motd: EmptyMotdProvider,
|
|
922
|
+
connection: state,
|
|
923
|
+
mtlsIdentity: new FakeMtlsIdentityProvider('c1', 'CN=alice'),
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
it('rejects EXTERNAL with the configured 904 message even when the three-way gate holds', () => {
|
|
928
|
+
// The override is authoritative: a transport that cannot surface a
|
|
929
|
+
// client-cert identity must reject up front with the transport-
|
|
930
|
+
// specific message, never fall through to the generic refusal —
|
|
931
|
+
// even if every gate input (mTLS source, opt-in, secure) is bound.
|
|
932
|
+
const state = saslReadyState();
|
|
933
|
+
const ctx = unsupportedCtx(state);
|
|
934
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
935
|
+
|
|
936
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
937
|
+
L(`:${SV} 904 alice :SASL EXTERNAL not supported on this transport`),
|
|
938
|
+
]);
|
|
939
|
+
expect(state.saslMech).toBeUndefined();
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it('does not count the transport-level 904 toward the failure lockout', () => {
|
|
943
|
+
// A transport-capability rejection is not a credential attempt; a
|
|
944
|
+
// client probing EXTERNAL must not burn the SASL failure budget.
|
|
945
|
+
const state = saslReadyState();
|
|
946
|
+
const ctx = unsupportedCtx(state);
|
|
947
|
+
|
|
948
|
+
for (let i = 0; i < 3; i++) {
|
|
949
|
+
const out = authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
950
|
+
expect(firstLineText(out.effects[0])).toContain(
|
|
951
|
+
' 904 alice :SASL EXTERNAL not supported on this transport',
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
expect(state.saslFailures).toBeUndefined();
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
it('lists PLAIN only in 908 when the transport override is set', () => {
|
|
958
|
+
const state = saslReadyState();
|
|
959
|
+
const ctx = unsupportedCtx(state);
|
|
960
|
+
const out = authenticateReducer(state, authenticate('CRAM-MD5'), ctx);
|
|
961
|
+
|
|
962
|
+
expect(sendLinesOf(out.effects[0])).toEqual<RawLine[]>([
|
|
963
|
+
L(`:${SV} 908 alice PLAIN :are the available SASL mechanisms`),
|
|
964
|
+
]);
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
|
|
638
968
|
// ============================================================================
|
|
639
969
|
// AUTHENTICATE <unknown mechanism>
|
|
640
970
|
// ============================================================================
|
|
@@ -954,3 +1284,262 @@ describe('authenticateReducer — NickServ-registered nick via ServicesStore', (
|
|
|
954
1284
|
expect(firstLineText(out.effects[0])).toBe(`:${SV} 904 alice :SASL authentication failed`);
|
|
955
1285
|
});
|
|
956
1286
|
});
|
|
1287
|
+
|
|
1288
|
+
// ============================================================================
|
|
1289
|
+
// Per-connection SASL failure lockout (brute-force hardening)
|
|
1290
|
+
// ============================================================================
|
|
1291
|
+
|
|
1292
|
+
/** Disconnect reason the reducer stamps once the failure lockout trips. */
|
|
1293
|
+
const SASL_LOCKOUT_REASON = 'too many failed SASL attempts';
|
|
1294
|
+
|
|
1295
|
+
describe('authenticateReducer — per-connection SASL failure lockout', () => {
|
|
1296
|
+
/**
|
|
1297
|
+
* Ctx builder with injectable clock + config so window/threshold tests can
|
|
1298
|
+
* advance time and override `sasl.maxFailures` / `sasl.failureWindowSeconds`.
|
|
1299
|
+
*/
|
|
1300
|
+
function lockoutCtx(
|
|
1301
|
+
state: ConnectionState,
|
|
1302
|
+
clock: FakeClock,
|
|
1303
|
+
config: ServerConfig = serverConfig,
|
|
1304
|
+
): Ctx {
|
|
1305
|
+
return buildCtx({
|
|
1306
|
+
serverConfig: config,
|
|
1307
|
+
clock,
|
|
1308
|
+
ids: new SequentialIdFactory(),
|
|
1309
|
+
motd: EmptyMotdProvider,
|
|
1310
|
+
connection: state,
|
|
1311
|
+
services: registeredServices('alice', 'secret'),
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/** Drives one full failed PLAIN exchange; returns the payload-frame effects. */
|
|
1316
|
+
function failedAttempt(state: ConnectionState, ctx: Ctx): EffectType[] {
|
|
1317
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
1318
|
+
const out = authenticateReducer(state, authenticate(plainPayload('alice', 'wrong')), ctx);
|
|
1319
|
+
return out.effects;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
it('emits 904 without a disconnect for the first two failed attempts', () => {
|
|
1323
|
+
const state = saslReadyState();
|
|
1324
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1325
|
+
|
|
1326
|
+
const first = failedAttempt(state, ctx);
|
|
1327
|
+
expect(first).toEqual<EffectType[]>([
|
|
1328
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1329
|
+
]);
|
|
1330
|
+
|
|
1331
|
+
const second = failedAttempt(state, ctx);
|
|
1332
|
+
expect(second).toEqual<EffectType[]>([
|
|
1333
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1334
|
+
]);
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
it('records failed-attempt timestamps on the connection state', () => {
|
|
1338
|
+
const state = saslReadyState();
|
|
1339
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1340
|
+
|
|
1341
|
+
failedAttempt(state, ctx);
|
|
1342
|
+
failedAttempt(state, ctx);
|
|
1343
|
+
|
|
1344
|
+
expect(state.saslFailures).toEqual([1_000, 1_000]);
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
it('disconnects after the third failed attempt, 904 delivered first', () => {
|
|
1348
|
+
const state = saslReadyState();
|
|
1349
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1350
|
+
|
|
1351
|
+
failedAttempt(state, ctx);
|
|
1352
|
+
failedAttempt(state, ctx);
|
|
1353
|
+
const third = failedAttempt(state, ctx);
|
|
1354
|
+
|
|
1355
|
+
expect(third).toEqual<EffectType[]>([
|
|
1356
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1357
|
+
Effect.disconnect('c1', SASL_LOCKOUT_REASON),
|
|
1358
|
+
]);
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
it('honours a configured sasl.maxFailures override', () => {
|
|
1362
|
+
const config: ServerConfig = { ...serverConfig, sasl: { maxFailures: 2 } };
|
|
1363
|
+
const state = saslReadyState();
|
|
1364
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000), config);
|
|
1365
|
+
|
|
1366
|
+
failedAttempt(state, ctx);
|
|
1367
|
+
const second = failedAttempt(state, ctx);
|
|
1368
|
+
|
|
1369
|
+
expect(second).toEqual<EffectType[]>([
|
|
1370
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1371
|
+
Effect.disconnect('c1', SASL_LOCKOUT_REASON),
|
|
1372
|
+
]);
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
it('does not trip when prior failures fall outside the window (sliding window)', () => {
|
|
1376
|
+
const state = saslReadyState();
|
|
1377
|
+
const clock = new FakeClock(1_000);
|
|
1378
|
+
const ctx = lockoutCtx(state, clock);
|
|
1379
|
+
|
|
1380
|
+
failedAttempt(state, ctx); // t = 1_000
|
|
1381
|
+
clock.advance(30_000);
|
|
1382
|
+
failedAttempt(state, ctx); // t = 31_000
|
|
1383
|
+
clock.advance(31_000);
|
|
1384
|
+
const third = failedAttempt(state, ctx); // t = 62_000; t = 1_000 is 61s old → pruned
|
|
1385
|
+
|
|
1386
|
+
expect(third).toEqual<EffectType[]>([
|
|
1387
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1388
|
+
]);
|
|
1389
|
+
expect(state.saslFailures).toEqual([31_000, 62_000]);
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
it('trips once three failures land inside the window again after pruning', () => {
|
|
1393
|
+
const state = saslReadyState();
|
|
1394
|
+
const clock = new FakeClock(1_000);
|
|
1395
|
+
const ctx = lockoutCtx(state, clock);
|
|
1396
|
+
|
|
1397
|
+
failedAttempt(state, ctx); // t = 1_000
|
|
1398
|
+
clock.advance(30_000);
|
|
1399
|
+
failedAttempt(state, ctx); // t = 31_000
|
|
1400
|
+
clock.advance(31_000);
|
|
1401
|
+
failedAttempt(state, ctx); // t = 62_000 (count: 2)
|
|
1402
|
+
clock.advance(1_000);
|
|
1403
|
+
const tripping = failedAttempt(state, ctx); // t = 63_000 (31_000/62_000/63_000 in window)
|
|
1404
|
+
|
|
1405
|
+
expect(tripping).toEqual<EffectType[]>([
|
|
1406
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1407
|
+
Effect.disconnect('c1', SASL_LOCKOUT_REASON),
|
|
1408
|
+
]);
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
it('honours a configured sasl.failureWindowSeconds override', () => {
|
|
1412
|
+
const config: ServerConfig = { ...serverConfig, sasl: { failureWindowSeconds: 1 } };
|
|
1413
|
+
const state = saslReadyState();
|
|
1414
|
+
const clock = new FakeClock(1_000);
|
|
1415
|
+
const ctx = lockoutCtx(state, clock, config);
|
|
1416
|
+
|
|
1417
|
+
failedAttempt(state, ctx); // t = 1_000
|
|
1418
|
+
clock.advance(1_100);
|
|
1419
|
+
failedAttempt(state, ctx); // t = 2_100 (t = 1_000 pruned)
|
|
1420
|
+
clock.advance(1_100);
|
|
1421
|
+
const third = failedAttempt(state, ctx); // t = 3_200 (t = 2_100 pruned)
|
|
1422
|
+
|
|
1423
|
+
expect(third).toEqual<EffectType[]>([
|
|
1424
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1425
|
+
]);
|
|
1426
|
+
expect(state.saslFailures).toEqual([3_200]);
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
it('resets the failure counter after a successful authentication', () => {
|
|
1430
|
+
const state = saslReadyState();
|
|
1431
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1432
|
+
|
|
1433
|
+
failedAttempt(state, ctx);
|
|
1434
|
+
failedAttempt(state, ctx);
|
|
1435
|
+
expect(state.saslFailures).toEqual([1_000, 1_000]);
|
|
1436
|
+
|
|
1437
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
1438
|
+
authenticateReducer(state, authenticate(plainPayload('alice', 'secret')), ctx);
|
|
1439
|
+
|
|
1440
|
+
expect(state.account).toBe('alice');
|
|
1441
|
+
expect(state.saslFailures).toBeUndefined();
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
it('does not count AUTHENTICATE * (abort) as a failure or reset the counter', () => {
|
|
1445
|
+
const state = saslReadyState();
|
|
1446
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1447
|
+
|
|
1448
|
+
failedAttempt(state, ctx); // count: 1
|
|
1449
|
+
|
|
1450
|
+
// Abort mid-exchange: clears the exchange scratch state but must neither
|
|
1451
|
+
// count as a failure nor let the attacker launder the counter away.
|
|
1452
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
1453
|
+
const abortOut = authenticateReducer(state, authenticate('*'), ctx);
|
|
1454
|
+
expect(firstLineText(abortOut.effects[0])).toContain(' 906 ');
|
|
1455
|
+
|
|
1456
|
+
failedAttempt(state, ctx); // count: 2 — no disconnect yet
|
|
1457
|
+
const third = failedAttempt(state, ctx); // count: 3 → trips
|
|
1458
|
+
|
|
1459
|
+
expect(third).toEqual<EffectType[]>([
|
|
1460
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1461
|
+
Effect.disconnect('c1', SASL_LOCKOUT_REASON),
|
|
1462
|
+
]);
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
it('does not count 908 unknown-mechanism rejections toward the lockout', () => {
|
|
1466
|
+
const state = saslReadyState();
|
|
1467
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1468
|
+
|
|
1469
|
+
for (let i = 0; i < 3; i++) {
|
|
1470
|
+
authenticateReducer(state, authenticate('CRAM-MD5'), ctx);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
const attempt = failedAttempt(state, ctx);
|
|
1474
|
+
expect(attempt).toEqual<EffectType[]>([
|
|
1475
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1476
|
+
]);
|
|
1477
|
+
expect(state.saslFailures).toEqual([1_000]);
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
it('does not count 905 ERR_SASLTOOLONG rejections toward the lockout', () => {
|
|
1481
|
+
const state = saslReadyState();
|
|
1482
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1483
|
+
|
|
1484
|
+
for (let i = 0; i < 3; i++) {
|
|
1485
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
1486
|
+
const out = authenticateReducer(state, authenticate(`${'A'.repeat(8193)}`), ctx);
|
|
1487
|
+
expect(firstLineText(out.effects[0])).toContain(' 905 ');
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const attempt = failedAttempt(state, ctx);
|
|
1491
|
+
expect(attempt).toEqual<EffectType[]>([
|
|
1492
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1493
|
+
]);
|
|
1494
|
+
expect(state.saslFailures).toEqual([1_000]);
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
it('counts malformed-base64 904 failures toward the lockout', () => {
|
|
1498
|
+
const state = saslReadyState();
|
|
1499
|
+
const ctx = lockoutCtx(state, new FakeClock(1_000));
|
|
1500
|
+
|
|
1501
|
+
for (let i = 0; i < 2; i++) {
|
|
1502
|
+
authenticateReducer(state, authenticate('PLAIN'), ctx);
|
|
1503
|
+
const out = authenticateReducer(state, authenticate('!!!notbase64!!!'), ctx);
|
|
1504
|
+
expect(firstLineText(out.effects[0])).toContain(' 904 ');
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const third = failedAttempt(state, ctx);
|
|
1508
|
+
expect(third).toEqual<EffectType[]>([
|
|
1509
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1510
|
+
Effect.disconnect('c1', SASL_LOCKOUT_REASON),
|
|
1511
|
+
]);
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
it('counts EXTERNAL verification failures toward the lockout', () => {
|
|
1515
|
+
const state = saslReadyState();
|
|
1516
|
+
const services = registeredServices('alice', 'secret');
|
|
1517
|
+
const provider = new FakeMtlsIdentityProvider('c1', undefined);
|
|
1518
|
+
const clock = new FakeClock(1_000);
|
|
1519
|
+
const ctx = buildCtx({
|
|
1520
|
+
// EXTERNAL must be opted in for the exchange to reach the verify
|
|
1521
|
+
// step whose failure the lockout counts.
|
|
1522
|
+
serverConfig: externalServerConfig,
|
|
1523
|
+
clock,
|
|
1524
|
+
ids: new SequentialIdFactory(),
|
|
1525
|
+
motd: EmptyMotdProvider,
|
|
1526
|
+
connection: state,
|
|
1527
|
+
services,
|
|
1528
|
+
mtlsIdentity: provider,
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
for (let i = 0; i < 2; i++) {
|
|
1532
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
1533
|
+
const out = authenticateReducer(state, authenticate('+'), ctx);
|
|
1534
|
+
expect(firstLineText(out.effects[0])).toContain(' 904 ');
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
authenticateReducer(state, authenticate('EXTERNAL'), ctx);
|
|
1538
|
+
const third = authenticateReducer(state, authenticate('+'), ctx);
|
|
1539
|
+
|
|
1540
|
+
expect(third.effects).toEqual<EffectType[]>([
|
|
1541
|
+
Effect.send('c1', [L(`:${SV} 904 alice :SASL authentication failed`)]),
|
|
1542
|
+
Effect.disconnect('c1', SASL_LOCKOUT_REASON),
|
|
1543
|
+
]);
|
|
1544
|
+
});
|
|
1545
|
+
});
|