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,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import pkg from '../../package.json' with { type: 'json' };
|
|
3
3
|
import { cloakHost } from '../../src/cloak';
|
|
4
4
|
import {
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
isValidNick,
|
|
14
14
|
nickReducer,
|
|
15
15
|
passReducer,
|
|
16
|
+
resetServerPasswordCompareSpy,
|
|
17
|
+
setServerPasswordCompare,
|
|
16
18
|
userReducer,
|
|
17
19
|
} from '../../src/commands/registration';
|
|
18
20
|
import { DEFAULT_CREATED_TEXT } from '../../src/config';
|
|
@@ -548,6 +550,340 @@ describe('userReducer', () => {
|
|
|
548
550
|
});
|
|
549
551
|
});
|
|
550
552
|
|
|
553
|
+
// ============================================================================
|
|
554
|
+
// userReducer — USER validation (charset + length + control chars)
|
|
555
|
+
// ============================================================================
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Builds the expected NOTICE line for a USER rejection addressed to the
|
|
559
|
+
* connection's current nick (or `*` when unregistered).
|
|
560
|
+
*/
|
|
561
|
+
function userRejectNotice(nick: string, text: string): RawLine {
|
|
562
|
+
return L(`:irc.example.com NOTICE ${nick} :${text}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Username-rejection NOTICE text (matches the reducer's tailored notice). */
|
|
566
|
+
const USERNAME_INVALID_TEXT =
|
|
567
|
+
'Invalid USER: username must be 1-16 bytes with no spaces, !, @, or control characters';
|
|
568
|
+
|
|
569
|
+
/** Realname-rejection NOTICE text (matches the reducer's tailored notice). */
|
|
570
|
+
const REALNAME_INVALID_TEXT =
|
|
571
|
+
'Invalid USER: realname must be at most 64 bytes with no control characters';
|
|
572
|
+
|
|
573
|
+
describe('userReducer — USER validation (charset + length)', () => {
|
|
574
|
+
// ------------------------------------------------------------------------
|
|
575
|
+
// Username charset rejections (params[0])
|
|
576
|
+
// ------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
it('rejects a username containing "!" with NOTICE + Disconnect (hostmask safety)', () => {
|
|
579
|
+
const state = makeState();
|
|
580
|
+
const ctx = makeCtx(state);
|
|
581
|
+
const out = userReducer(
|
|
582
|
+
state,
|
|
583
|
+
{ command: 'USER', params: ['al!ce', '0', '*', 'Alice'], tags: {} },
|
|
584
|
+
ctx,
|
|
585
|
+
);
|
|
586
|
+
expect(out.state.user).toBeUndefined();
|
|
587
|
+
expect(out.state.realname).toBeUndefined();
|
|
588
|
+
expect(out.state.registration).toBe('pre-registration');
|
|
589
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
590
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
591
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
592
|
+
]);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('rejects a username containing "@" with NOTICE + Disconnect (hostmask safety)', () => {
|
|
596
|
+
const state = makeState();
|
|
597
|
+
const ctx = makeCtx(state);
|
|
598
|
+
const out = userReducer(
|
|
599
|
+
state,
|
|
600
|
+
{ command: 'USER', params: ['al@ce', '0', '*', 'Alice'], tags: {} },
|
|
601
|
+
ctx,
|
|
602
|
+
);
|
|
603
|
+
expect(out.state.user).toBeUndefined();
|
|
604
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
605
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
606
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
607
|
+
]);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('rejects a username containing a space with NOTICE + Disconnect', () => {
|
|
611
|
+
const state = makeState();
|
|
612
|
+
const ctx = makeCtx(state);
|
|
613
|
+
const out = userReducer(
|
|
614
|
+
state,
|
|
615
|
+
{ command: 'USER', params: ['al ce', '0', '*', 'Alice'], tags: {} },
|
|
616
|
+
ctx,
|
|
617
|
+
);
|
|
618
|
+
expect(out.state.user).toBeUndefined();
|
|
619
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
620
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
621
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
622
|
+
]);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('rejects a username containing NUL (\\0) with NOTICE + Disconnect', () => {
|
|
626
|
+
const state = makeState();
|
|
627
|
+
const ctx = makeCtx(state);
|
|
628
|
+
const out = userReducer(
|
|
629
|
+
state,
|
|
630
|
+
{ command: 'USER', params: ['al\0ce', '0', '*', 'Alice'], tags: {} },
|
|
631
|
+
ctx,
|
|
632
|
+
);
|
|
633
|
+
expect(out.state.user).toBeUndefined();
|
|
634
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
635
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
636
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
637
|
+
]);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('rejects a username containing a bare CR (\\r) with NOTICE + Disconnect', () => {
|
|
641
|
+
const state = makeState();
|
|
642
|
+
const ctx = makeCtx(state);
|
|
643
|
+
const out = userReducer(
|
|
644
|
+
state,
|
|
645
|
+
{ command: 'USER', params: ['al\rce', '0', '*', 'Alice'], tags: {} },
|
|
646
|
+
ctx,
|
|
647
|
+
);
|
|
648
|
+
expect(out.state.user).toBeUndefined();
|
|
649
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
650
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
651
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
652
|
+
]);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('rejects a username containing a bare LF (\\n) with NOTICE + Disconnect', () => {
|
|
656
|
+
const state = makeState();
|
|
657
|
+
const ctx = makeCtx(state);
|
|
658
|
+
const out = userReducer(
|
|
659
|
+
state,
|
|
660
|
+
{ command: 'USER', params: ['al\nce', '0', '*', 'Alice'], tags: {} },
|
|
661
|
+
ctx,
|
|
662
|
+
);
|
|
663
|
+
expect(out.state.user).toBeUndefined();
|
|
664
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
665
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
666
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
667
|
+
]);
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it('rejects a username containing a control char (\\x01) with NOTICE + Disconnect', () => {
|
|
671
|
+
const state = makeState();
|
|
672
|
+
const ctx = makeCtx(state);
|
|
673
|
+
const out = userReducer(
|
|
674
|
+
state,
|
|
675
|
+
{ command: 'USER', params: ['al\x01ce', '0', '*', 'Alice'], tags: {} },
|
|
676
|
+
ctx,
|
|
677
|
+
);
|
|
678
|
+
expect(out.state.user).toBeUndefined();
|
|
679
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
680
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
681
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
682
|
+
]);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// ------------------------------------------------------------------------
|
|
686
|
+
// Username length rejection (USERLEN = 16 byte cap)
|
|
687
|
+
// ------------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
it('rejects a 17-byte username with NOTICE + Disconnect (USERLEN = 16)', () => {
|
|
690
|
+
const state = makeState();
|
|
691
|
+
const ctx = makeCtx(state);
|
|
692
|
+
const longUser = 'a'.repeat(17);
|
|
693
|
+
const out = userReducer(
|
|
694
|
+
state,
|
|
695
|
+
{ command: 'USER', params: [longUser, '0', '*', 'Alice'], tags: {} },
|
|
696
|
+
ctx,
|
|
697
|
+
);
|
|
698
|
+
expect(out.state.user).toBeUndefined();
|
|
699
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
700
|
+
Effect.send('c1', [userRejectNotice('*', USERNAME_INVALID_TEXT)]),
|
|
701
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
702
|
+
]);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('accepts a username at exactly the 16-byte USERLEN cap (boundary)', () => {
|
|
706
|
+
const state = makeState();
|
|
707
|
+
const ctx = makeCtx(state);
|
|
708
|
+
const exactUser = 'a'.repeat(16);
|
|
709
|
+
const out = userReducer(
|
|
710
|
+
state,
|
|
711
|
+
{ command: 'USER', params: [exactUser, '0', '*', 'Alice'], tags: {} },
|
|
712
|
+
ctx,
|
|
713
|
+
);
|
|
714
|
+
expect(out.state.user).toBe(exactUser);
|
|
715
|
+
expect(out.state.realname).toBe('Alice');
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// ------------------------------------------------------------------------
|
|
719
|
+
// Realname charset rejections (params[3])
|
|
720
|
+
// ------------------------------------------------------------------------
|
|
721
|
+
|
|
722
|
+
it('rejects a realname containing NUL (\\0) with NOTICE + Disconnect', () => {
|
|
723
|
+
const state = makeState();
|
|
724
|
+
const ctx = makeCtx(state);
|
|
725
|
+
const out = userReducer(
|
|
726
|
+
state,
|
|
727
|
+
{ command: 'USER', params: ['alice', '0', '*', 'Al\0ice'], tags: {} },
|
|
728
|
+
ctx,
|
|
729
|
+
);
|
|
730
|
+
expect(out.state.user).toBeUndefined();
|
|
731
|
+
expect(out.state.realname).toBeUndefined();
|
|
732
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
733
|
+
Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
|
|
734
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
735
|
+
]);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('rejects a realname containing a bare CR (\\r) with NOTICE + Disconnect (CRLF injection)', () => {
|
|
739
|
+
const state = makeState();
|
|
740
|
+
const ctx = makeCtx(state);
|
|
741
|
+
const out = userReducer(
|
|
742
|
+
state,
|
|
743
|
+
{ command: 'USER', params: ['alice', '0', '*', 'Al\rice'], tags: {} },
|
|
744
|
+
ctx,
|
|
745
|
+
);
|
|
746
|
+
expect(out.state.realname).toBeUndefined();
|
|
747
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
748
|
+
Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
|
|
749
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
750
|
+
]);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('rejects a realname containing a bare LF (\\n) with NOTICE + Disconnect', () => {
|
|
754
|
+
const state = makeState();
|
|
755
|
+
const ctx = makeCtx(state);
|
|
756
|
+
const out = userReducer(
|
|
757
|
+
state,
|
|
758
|
+
{ command: 'USER', params: ['alice', '0', '*', 'Al\nice'], tags: {} },
|
|
759
|
+
ctx,
|
|
760
|
+
);
|
|
761
|
+
expect(out.state.realname).toBeUndefined();
|
|
762
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
763
|
+
Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
|
|
764
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
765
|
+
]);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('rejects a realname containing a control char (\\x01) with NOTICE + Disconnect', () => {
|
|
769
|
+
const state = makeState();
|
|
770
|
+
const ctx = makeCtx(state);
|
|
771
|
+
const out = userReducer(
|
|
772
|
+
state,
|
|
773
|
+
{ command: 'USER', params: ['alice', '0', '*', 'Al\x01ice'], tags: {} },
|
|
774
|
+
ctx,
|
|
775
|
+
);
|
|
776
|
+
expect(out.state.realname).toBeUndefined();
|
|
777
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
778
|
+
Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
|
|
779
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
780
|
+
]);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// ------------------------------------------------------------------------
|
|
784
|
+
// Realname length rejection (REALLEN = 64 byte cap, multi-byte aware)
|
|
785
|
+
// ------------------------------------------------------------------------
|
|
786
|
+
|
|
787
|
+
it('rejects a 65-byte realname with NOTICE + Disconnect (REALLEN = 64)', () => {
|
|
788
|
+
const state = makeState();
|
|
789
|
+
const ctx = makeCtx(state);
|
|
790
|
+
const longReal = 'A'.repeat(65);
|
|
791
|
+
const out = userReducer(
|
|
792
|
+
state,
|
|
793
|
+
{ command: 'USER', params: ['alice', '0', '*', longReal], tags: {} },
|
|
794
|
+
ctx,
|
|
795
|
+
);
|
|
796
|
+
expect(out.state.realname).toBeUndefined();
|
|
797
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
798
|
+
Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
|
|
799
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
800
|
+
]);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it('rejects a multi-byte UTF-8 realname exceeding the 64-byte budget', () => {
|
|
804
|
+
const state = makeState();
|
|
805
|
+
const ctx = makeCtx(state);
|
|
806
|
+
// Each '€' is 3 bytes in UTF-8; 22 × 3 = 66 bytes > 64.
|
|
807
|
+
const overBudget = '€'.repeat(22);
|
|
808
|
+
const out = userReducer(
|
|
809
|
+
state,
|
|
810
|
+
{ command: 'USER', params: ['alice', '0', '*', overBudget], tags: {} },
|
|
811
|
+
ctx,
|
|
812
|
+
);
|
|
813
|
+
expect(out.state.realname).toBeUndefined();
|
|
814
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
815
|
+
Effect.send('c1', [userRejectNotice('*', REALNAME_INVALID_TEXT)]),
|
|
816
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
817
|
+
]);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('accepts a multi-byte UTF-8 realname within the 64-byte budget (regression guard)', () => {
|
|
821
|
+
const state = makeState();
|
|
822
|
+
const ctx = makeCtx(state);
|
|
823
|
+
// 21 × 3 = 63 bytes ≤ 64.
|
|
824
|
+
const withinBudget = '€'.repeat(21);
|
|
825
|
+
const out = userReducer(
|
|
826
|
+
state,
|
|
827
|
+
{ command: 'USER', params: ['alice', '0', '*', withinBudget], tags: {} },
|
|
828
|
+
ctx,
|
|
829
|
+
);
|
|
830
|
+
expect(out.state.user).toBe('alice');
|
|
831
|
+
expect(out.state.realname).toBe(withinBudget);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it('accepts a realname at exactly the 64-byte REALLEN cap (boundary)', () => {
|
|
835
|
+
const state = makeState();
|
|
836
|
+
const ctx = makeCtx(state);
|
|
837
|
+
const exactReal = 'A'.repeat(64);
|
|
838
|
+
const out = userReducer(
|
|
839
|
+
state,
|
|
840
|
+
{ command: 'USER', params: ['alice', '0', '*', exactReal], tags: {} },
|
|
841
|
+
ctx,
|
|
842
|
+
);
|
|
843
|
+
expect(out.state.realname).toBe(exactReal);
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it('does not half-store state when the username is valid but the realname is invalid', () => {
|
|
847
|
+
const state = makeState();
|
|
848
|
+
const ctx = makeCtx(state);
|
|
849
|
+
const out = userReducer(
|
|
850
|
+
state,
|
|
851
|
+
{ command: 'USER', params: ['alice', '0', '*', 'Al\rice'], tags: {} },
|
|
852
|
+
ctx,
|
|
853
|
+
);
|
|
854
|
+
expect(out.state.user).toBeUndefined();
|
|
855
|
+
expect(out.state.realname).toBeUndefined();
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('uses the current nick (not *) in the rejection NOTICE when partially registered', () => {
|
|
859
|
+
const state = makeState();
|
|
860
|
+
state.nick = 'bob';
|
|
861
|
+
state.registration = 'registering';
|
|
862
|
+
const ctx = makeCtx(state);
|
|
863
|
+
const out = userReducer(
|
|
864
|
+
state,
|
|
865
|
+
{ command: 'USER', params: ['b!ob', '0', '*', 'Bob'], tags: {} },
|
|
866
|
+
ctx,
|
|
867
|
+
);
|
|
868
|
+
expect(out.effects).toEqual<EffectType[]>([
|
|
869
|
+
Effect.send('c1', [userRejectNotice('bob', USERNAME_INVALID_TEXT)]),
|
|
870
|
+
Effect.disconnect('c1', 'invalid USER'),
|
|
871
|
+
]);
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
it('still allows a realname containing spaces (valid realname)', () => {
|
|
875
|
+
const state = makeState();
|
|
876
|
+
const ctx = makeCtx(state);
|
|
877
|
+
const out = userReducer(
|
|
878
|
+
state,
|
|
879
|
+
{ command: 'USER', params: ['alice', '0', '*', 'Alice Wonderland'], tags: {} },
|
|
880
|
+
ctx,
|
|
881
|
+
);
|
|
882
|
+
expect(out.state.user).toBe('alice');
|
|
883
|
+
expect(out.state.realname).toBe('Alice Wonderland');
|
|
884
|
+
});
|
|
885
|
+
});
|
|
886
|
+
|
|
551
887
|
// ============================================================================
|
|
552
888
|
// passReducer
|
|
553
889
|
// ============================================================================
|
|
@@ -1097,6 +1433,132 @@ describe('emitWelcomeIfReady — server-password gate', () => {
|
|
|
1097
1433
|
});
|
|
1098
1434
|
});
|
|
1099
1435
|
|
|
1436
|
+
// ============================================================================
|
|
1437
|
+
// server-password gate — constant-time comparison
|
|
1438
|
+
// ----------------------------------------------------------------------------
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* Runs one gate evaluation against the configured password `s3cret` and
|
|
1442
|
+
* returns the effects, so the tests below can focus on comparator calls.
|
|
1443
|
+
*/
|
|
1444
|
+
function runGate(passAttempt: string | undefined): EffectType[] {
|
|
1445
|
+
const state = makeState();
|
|
1446
|
+
state.nick = 'alice';
|
|
1447
|
+
state.user = 'alice';
|
|
1448
|
+
if (passAttempt !== undefined) state.passAttempt = passAttempt;
|
|
1449
|
+
return emitWelcomeIfReady(state, makeCtxWithPassword(state, 's3cret'));
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* Installs a call-counting comparator that behaves like string equality so
|
|
1454
|
+
* gate outcomes stay behavioral while the delegation is observable.
|
|
1455
|
+
*/
|
|
1456
|
+
function installCountingCompare(): ReturnType<typeof vi.fn> {
|
|
1457
|
+
const compare = vi.fn((a: string, b: string) => a === b);
|
|
1458
|
+
setServerPasswordCompare(compare);
|
|
1459
|
+
return compare;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
describe('passwordGateFailure — constant-time comparison', () => {
|
|
1463
|
+
afterEach(() => {
|
|
1464
|
+
resetServerPasswordCompareSpy();
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
it('compares the attempt against the configured password via constantTimeEquals', () => {
|
|
1468
|
+
const compare = installCountingCompare();
|
|
1469
|
+
try {
|
|
1470
|
+
runGate('wrong');
|
|
1471
|
+
expect(compare).toHaveBeenCalledTimes(1);
|
|
1472
|
+
expect(compare).toHaveBeenCalledWith('wrong', 's3cret');
|
|
1473
|
+
} finally {
|
|
1474
|
+
setServerPasswordCompare(null);
|
|
1475
|
+
}
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
it('compares exactly once on the equal-password bucket (gate satisfied)', () => {
|
|
1479
|
+
const compare = installCountingCompare();
|
|
1480
|
+
try {
|
|
1481
|
+
const effects = runGate('s3cret');
|
|
1482
|
+
expect(compare).toHaveBeenCalledTimes(1);
|
|
1483
|
+
expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
|
|
1484
|
+
} finally {
|
|
1485
|
+
setServerPasswordCompare(null);
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
it('compares exactly once on the same-length unequal bucket (464 + Disconnect)', () => {
|
|
1490
|
+
const compare = installCountingCompare();
|
|
1491
|
+
try {
|
|
1492
|
+
const effects = runGate('X3cret');
|
|
1493
|
+
expect(compare).toHaveBeenCalledTimes(1);
|
|
1494
|
+
expect(effects).toEqual<EffectType[]>([
|
|
1495
|
+
Effect.send('c1', [L(':irc.example.com 464 alice :Password mismatch')]),
|
|
1496
|
+
Effect.disconnect('c1', 'Bad Password'),
|
|
1497
|
+
]);
|
|
1498
|
+
} finally {
|
|
1499
|
+
setServerPasswordCompare(null);
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
it('compares exactly once on the different-length bucket — no length pre-check bypass', () => {
|
|
1504
|
+
const compare = installCountingCompare();
|
|
1505
|
+
try {
|
|
1506
|
+
const effects = runGate('s3cr');
|
|
1507
|
+
expect(compare).toHaveBeenCalledTimes(1);
|
|
1508
|
+
expect(compare).toHaveBeenCalledWith('s3cr', 's3cret');
|
|
1509
|
+
expect(effects).toEqual<EffectType[]>([
|
|
1510
|
+
Effect.send('c1', [L(':irc.example.com 464 alice :Password mismatch')]),
|
|
1511
|
+
Effect.disconnect('c1', 'Bad Password'),
|
|
1512
|
+
]);
|
|
1513
|
+
} finally {
|
|
1514
|
+
setServerPasswordCompare(null);
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
it('compares the empty placeholder once when no PASS was sent', () => {
|
|
1519
|
+
const compare = installCountingCompare();
|
|
1520
|
+
try {
|
|
1521
|
+
runGate(undefined);
|
|
1522
|
+
expect(compare).toHaveBeenCalledTimes(1);
|
|
1523
|
+
expect(compare).toHaveBeenCalledWith('', 's3cret');
|
|
1524
|
+
} finally {
|
|
1525
|
+
setServerPasswordCompare(null);
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
it('does not compare when the gate is satisfied by SASL (account set)', () => {
|
|
1530
|
+
const state = makeState();
|
|
1531
|
+
state.nick = 'alice';
|
|
1532
|
+
state.user = 'alice';
|
|
1533
|
+
state.account = 'alice';
|
|
1534
|
+
const compare = installCountingCompare();
|
|
1535
|
+
try {
|
|
1536
|
+
const effects = emitWelcomeIfReady(state, makeCtxWithPassword(state, 's3cret'));
|
|
1537
|
+
expect(compare).not.toHaveBeenCalled();
|
|
1538
|
+
expect(state.registration).toBe('registered');
|
|
1539
|
+
expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
|
|
1540
|
+
} finally {
|
|
1541
|
+
setServerPasswordCompare(null);
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
it('does not compare when no server password is configured (gate disabled)', () => {
|
|
1546
|
+
const state = makeState();
|
|
1547
|
+
state.nick = 'alice';
|
|
1548
|
+
state.user = 'alice';
|
|
1549
|
+
const compare = installCountingCompare();
|
|
1550
|
+
try {
|
|
1551
|
+
const ctx = makeCtxWithPassword(state, '');
|
|
1552
|
+
const effects = emitWelcomeIfReady(state, ctx);
|
|
1553
|
+
expect(compare).not.toHaveBeenCalled();
|
|
1554
|
+
expect(state.registration).toBe('registered');
|
|
1555
|
+
expect(effects).toEqual([Effect.send('c1', expectedWelcome('alice', 'alice', undefined))]);
|
|
1556
|
+
} finally {
|
|
1557
|
+
setServerPasswordCompare(null);
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1100
1562
|
// ============================================================================
|
|
1101
1563
|
// userReducer / nickReducer — server-password gate integration
|
|
1102
1564
|
// ============================================================================
|