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.
Files changed (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
package/README.md CHANGED
@@ -54,7 +54,28 @@ One TypeScript codebase. Two serverless substrates.
54
54
  > (`DataTraceEnabled`) is **off by default and hard-locked**, so IRC
55
55
  > frames (`PASS`, `AUTHENTICATE <SASL-PLAIN>`, channel keys,
56
56
  > `PRIVMSG`/`NOTICE`) are never written to CloudWatch; the only way
57
- > back on is an explicit two-flag sandbox escape hatch.
57
+ > back on is an explicit two-flag sandbox escape hatch. The CF deploy
58
+ > workflow refuses placeholder hostnames (`irc.example.com` /
59
+ > `irc.your-domain.invalid`), and a CI **env-var drift guard** keeps
60
+ > the `consumed-env-vars` block of `apps/cf-worker/wrangler.toml`
61
+ > exactly in sync with the config loader.
62
+ >
63
+ > **Abuse controls & hardening.** Layered admission control on both
64
+ > platforms: a global `MAX_CLIENTS` cap (Cloudflare reserves a
65
+ > `CounterDO` slot per upgrade; AWS keeps an atomic connection counter
66
+ > — over-cap connects get `429`), per-IP simultaneous-connection caps
67
+ > and sliding-window connect-rate limits (a `RateLimitDO` at the CF
68
+ > edge; a source-IP GSI count + APIGW stage throttling + an opt-in
69
+ > WAFv2 per-IP rate rule on AWS), and a per-connection inbound
70
+ > frame-rate window at every adapter boundary. Credential handling is
71
+ > hardened end-to-end: scrypt-hashed oper credentials, constant-time
72
+ > server-password comparison, timing-equalized nick verification (no
73
+ > account enumeration), SASL/OPER/IDENTIFY brute-force lockouts, and
74
+ > credential redaction from parse-error logs. SASL `EXTERNAL` is
75
+ > **operator opt-in** (`EXTERNAL_ENABLED`, default off), gated on a
76
+ > bound mTLS identity source **and** a TLS transport, and binds
77
+ > accounts to the client cert's **DER SHA-256 fingerprint**
78
+ > (`fp:<hex>`, with a canonical-DN fallback).
58
79
  >
59
80
  > **Integrated IRC services.** NickServ, ChanServ, HostServ,
60
81
  > OperServ, and MemoServ run inside the daemon (no separate services
@@ -121,12 +142,18 @@ Hexagonal / ports-and-adapters. The core implements the IRC protocol; adapters h
121
142
  **Two transports, one core.** Both adapters accept WebSocket text frames
122
143
  (the serverless default) **and** a raw `irc+tls :6697` (TLS-over-TCP)
123
144
  surface for stock IRC clients. The TCP+TLS edge is platform-specific —
124
- Cloudflare **Spectrum** terminates TLS and forwards plaintext TCP to a
125
- stateful **Container** origin (`apps/cf-tcp-container`); AWS terminates
126
- TLS at a **Network Load Balancer** and invokes a **Lambda streaming**
127
- function. Both feed the same `ConnectionActor` through a `Transport`
128
- seam (`WsTextFrameTransport` vs. `TcpByteStreamTransport`); the
129
- parser/reducer/dispatch pipeline is identical.
145
+ Cloudflare **Spectrum** fronts a stateful **Container** origin
146
+ (`apps/cf-tcp-container`) with `proxy_protocol = "v1"` and
147
+ `tls_mode = "full"`: the container itself terminates TLS (pinned to
148
+ 1.2/1.3), requires and parses a PROXY v1 header on every flow to recover
149
+ the real client IP, and runs as a non-root user. AWS terminates TLS at a
150
+ **Network Load Balancer** — the streaming handler asserts the flow is
151
+ TLS-secured (surfacing user mode `S`), rejects missing or malformed flow
152
+ headers, and enforces the same per-IP connect-rate budget — and invokes a
153
+ **Lambda streaming** function. Both feed the same `ConnectionActor`
154
+ through a `Transport` seam (`WsTextFrameTransport` vs.
155
+ `TcpByteStreamTransport`); the parser/reducer/dispatch pipeline is
156
+ identical.
130
157
 
131
158
  ### Design: pure reducers + location-of-authority
132
159
 
@@ -177,9 +204,11 @@ ServerlessIRCd/
177
204
  ├── tools/
178
205
  │ ├── tcp-ws-forwarder/ local TCP↔ws/wss bridge for stock IRC clients
179
206
  │ ├── load-test/ synthetic WebSocket IRC client pool (10k conns, p50/p95/p99, drop rate)
180
- │ ├── ci-hardening/ coverage-gate + mutation-config validators
207
+ │ ├── ci-hardening/ coverage-gate + mutation-config + env-var-drift validators
208
+ │ ├── hash-oper-cred.ts scrypt oper-credential generator (OPER_SALT + OPER_HASH)
181
209
  │ ├── seed-aws-accounts.ts scrypt-hash SASL PLAIN accounts into DynamoDB
182
- └── seed-cf-accounts.ts scrypt-hash SASL PLAIN accounts into Cloudflare D1
210
+ ├── seed-cf-accounts.ts scrypt-hash SASL PLAIN accounts into Cloudflare D1
211
+ │ └── migrate-accounts-to-services.ts one-shot AccountStore→ServicesStore credential backfill
183
212
  ├── scripts/
184
213
  │ └── deploy-web-aws.mjs stack-output-driven AWS web client deploy (describe → bake → s3 sync → invalidate)
185
214
  ├── pnpm-workspace.yaml turbo.json tsconfig.base.json
@@ -241,8 +270,8 @@ pnpm --filter web build # builds the Kiwi SPA into apps/web/dist/webclient/ (/
241
270
  Coverage reports are written to `packages/*/coverage/`. CI (`.github/workflows/ci.yml`)
242
271
  runs lint, typecheck, the coverage gate, the parametrized contract suite, and a
243
272
  Stryker mutation spot-check on every push and pull request. Coverage thresholds
244
- enforce 100% on `irc-core` / `irc-server` / `in-memory-runtime` and ≥90% on every
245
- other package.
273
+ enforce 100% on `irc-core` / `irc-server` / `in-memory-runtime` / `ci-hardening`
274
+ and ≥90% on every other package.
246
275
 
247
276
  ---
248
277
 
@@ -522,6 +551,27 @@ Deploy the Worker (Worker + assets in one command):
522
551
  pnpm deploy:cf # wrangler deploy
523
552
  ```
524
553
 
554
+ #### Configuration vars & secrets (Cloudflare)
555
+
556
+ Every env var the Worker consumes is enumerated — and
557
+ **drift-guarded in CI** — in the `consumed-env-vars` block of
558
+ `apps/cf-worker/wrangler.toml`, classified as a plaintext `[vars]`
559
+ knob or a `[secret]`. Credential material must be set as Workers
560
+ secrets, never `[vars]`:
561
+
562
+ | Secret | Purpose |
563
+ |--------|---------|
564
+ | `SERVER_PASSWORD` | server-wide PASS gate (see [above](#server-password-server_password---server-password)) |
565
+ | `OPER_PASSWORD` | legacy plaintext oper credential |
566
+ | `OPER_SALT` + `OPER_HASH` | hashed oper credential — generate with `node --import tsx tools/hash-oper-cred.ts --user admin --stdin` |
567
+ | `SASL_ACCOUNTS` | newline-delimited `user:password` SASL seed list (see `tools/seed-cf-accounts.ts`) |
568
+
569
+ Set each with `wrangler secret put <NAME>`. Everything else
570
+ (`SERVER_NAME`, `MAX_CLIENTS`, `MAX_CONNECTIONS_PER_IP`,
571
+ `PER_IP_CONNECTION_RATE_*`, `MAX_FRAMES_PER_WINDOW`,
572
+ `FRAME_WINDOW_SECONDS`, `EXTERNAL_ENABLED`, …) is a non-sensitive
573
+ `[vars]` knob.
574
+
525
575
  ### Deploying on AWS
526
576
 
527
577
  The web client is **opt-in**: provision the `StaticSite` construct by
@@ -630,6 +680,73 @@ deployed stack exercises the spec path end-to-end.
630
680
 
631
681
  ---
632
682
 
683
+ ## Abuse controls & credential hardening
684
+
685
+ Every transport edge enforces layered admission and rate limits, and
686
+ every credential path verifies through hardened, timing-equalized
687
+ comparisons. The knobs below are `[vars]` on the Cloudflare Worker and
688
+ mirrored as Lambda env vars on AWS.
689
+
690
+ ### Connection admission & rate limiting
691
+
692
+ | Layer | Knobs | Enforcement |
693
+ |------------------------|---------------------------------------------------|-------------|
694
+ | Global cap | `MAX_CLIENTS` | CF reserves a `CounterDO` slot before each upgrade and answers `429` (`ERROR :Closing link: server full`) at the cap (slots released on close, TTL-reaped if a DO dies); AWS keeps an atomic connection counter in DynamoDB. |
695
+ | Per-IP simultaneous | `MAX_CONNECTIONS_PER_IP` | Admission gates on both platforms reject over-budget source IPs with `429`. |
696
+ | Per-IP connect rate | `PER_IP_CONNECTION_RATE_MAX` / `PER_IP_CONNECTION_RATE_WINDOW_MS` | CF: a `RateLimitDO` sliding window keyed on `CF-Connecting-IP`, checked at the worker edge (rejections never consume budget, so a blocked IP recovers after window decay). AWS: `$connect` counts in-window establishments via a `sourceIp+connectedSince` GSI, the NLB path applies the same budget to new flows, APIGW stage throttling backstops globally, and an opt-in WAFv2 per-IP rate rule (`-c wafConnectRateLimit=<n>`) sits at the edge. |
697
+ | Per-connection frames | `MAX_FRAMES_PER_WINDOW` / `FRAME_WINDOW_SECONDS` | Inbound frame budget enforced at the adapter boundary before the actor / storage write. |
698
+ | Line-buffer memory | (fixed) | TCP input buffers capped at 8 KiB at every transport edge (local CLI, container origin, NLB handler, forwarder). |
699
+
700
+ ### Protocol budgets
701
+
702
+ All length limits are enforced in **UTF-8 bytes**, not UTF-16 code
703
+ units: the 510-byte WS frame / 512-byte TCP line budgets, `TOPICLEN`
704
+ enforced on character boundaries, `draft/multiline` batch byte budgets
705
+ enforced incrementally as lines arrive, at most 15 tags and an
706
+ 8192-byte tag section per message, long NAMES rosters split across
707
+ multiple `353` replies within the 510-byte budget, `CHATHISTORY`
708
+ limits capped at a configurable ceiling (default 100), and
709
+ `MAX_TARGETS_PER_COMMAND` capping comma-split targets. The parser
710
+ rejects bare-`CR` smuggling and NUL/control characters in channel
711
+ names, builds tag maps with a null prototype (blocking prototype
712
+ pollution), and `OPER` requires a registered connection (`451
713
+ ERR_NOTREGISTERED`).
714
+
715
+ ### Credentials & anti-abuse
716
+
717
+ Oper credentials verify against scrypt hashes (`OPER_USER` +
718
+ `OPER_SALT`/`OPER_HASH`, generated with `tools/hash-oper-cred.ts`);
719
+ the server password compares in constant time; `verifyNick` runs a
720
+ dummy scrypt verify on unknown nicks so response timings cannot
721
+ enumerate accounts. Brute force is throttled at every auth surface: a
722
+ per-connection SASL failure lockout, a per-IP `OPER` failure lockout,
723
+ and a per-account NickServ `IDENTIFY` freeze. HostServ auto-approve
724
+ honours a vhost denylist/allowlist, and ChanServ `DEOP`/`KICK` protect
725
+ founders and enforce caller rank. Parse-error logs carry only
726
+ token/length/reason — `PASS` / `AUTHENTICATE` payloads are redacted —
727
+ and the Worker's log sampling defaults to 10%.
728
+
729
+ ### SASL EXTERNAL (mTLS)
730
+
731
+ `EXTERNAL` is advertised and accepted only when **all** of the
732
+ following hold: an mTLS identity source is bound for the connection
733
+ (CF API Shield / AWS API Gateway client certs), the operator opt-in
734
+ `EXTERNAL_ENABLED` is set (default **off**), and the transport is
735
+ TLS-secured. Otherwise the `sasl` cap and `908 ERR_SASLMECHS` list
736
+ `PLAIN` only — refusals do not count toward the SASL lockout.
737
+ Accounts bind to the client cert's **DER SHA-256 fingerprint**
738
+ (`fp:<hex>` entries — what Cloudflare surfaces from
739
+ `request.cf.tlsClientAuth`; the preferred binding) or a **canonical
740
+ subject DN** (the only identifier API Gateway exposes; DNs are
741
+ canonicalised — types lower-cased, whitespace collapsed, RDN/AVA order
742
+ sorted — so re-issued or differently-ordered certificates still match).
743
+ Transports without any client-cert surface — the Spectrum container
744
+ origin, and the NLB stream handler unless an mTLS provider is injected
745
+ — pin the mechanism off and reject `AUTHENTICATE EXTERNAL` with a
746
+ transport-specific `904`.
747
+
748
+ ---
749
+
633
750
  ## Testing strategy
634
751
 
635
752
  This project follows strict TDD (Red → Green → Refactor) — every reducer is
@@ -666,12 +783,17 @@ Active follow-ups:
666
783
  surfaces.
667
784
  - **Web client e2e** — Playwright headless-browser e2e exercising the
668
785
  vendored Kiwi IRC SPA against a deployed stack.
669
- - **Coverage hardening** — `aws-adapter` and `aws-stack` clear the 90%
670
- gate; the CF packages run under istanbul. The remaining packages
671
- (`cf-adapter`, `local-cli`, `load-test`, `cf-tcp-container`,
672
- `tcp-ws-forwarder`, `irc-test-support`, `web`) sit above the gate
673
- but below 100%; follow-ups drive each to full coverage. `irc-core`,
674
- `irc-server`, and `in-memory-runtime` are at 100%.
786
+ - **Coverage hardening** — `irc-core`, `irc-server`,
787
+ `in-memory-runtime`, `local-cli`, `cf-worker`, `aws-stack`,
788
+ `load-test`, `cf-tcp-container`, `tcp-ws-forwarder`,
789
+ `irc-test-support`, and `ci-hardening` sit at 100% line coverage;
790
+ `cf-adapter` (~99.7%) and `aws-adapter` (~98%) clear the 90% gate
791
+ with follow-ups driving each to full coverage.
792
+ - **Protocol follow-ups** — `WHOX` (`WHO <mask> %<fields>` /
793
+ `354 RPL_WHOSPCRPL`), `LIST` search masks + `ELIST=MNTU` filters,
794
+ `cap-notify` capability-change push, services data lifecycle
795
+ (last-used tracking + expiry sweep), and a persisted oper audit
796
+ trail.
675
797
  - **Persistent ChanServ ban list** — ban masks currently live on
676
798
  `ChannelState.banMasks` and do not survive an empty-recreate of a
677
799
  channel. Extending `ServicesStore` with a persistent ban list is the
@@ -747,17 +869,28 @@ The `ServicesStore` is the **single credential home**: SASL PLAIN, SASL
747
869
  EXTERNAL (CertFP), NickServ `IDENTIFY`, and `PASS <nick>:<password>` all
748
870
  verify through the same scrypt-hashed `verifyNick` / `verifyCertFP`
749
871
  surface, so a registered nick is also a SASL login and vice versa.
872
+ The credential env vars that seed and unlock these paths
873
+ (`SASL_ACCOUNTS`, `OPER_*`, `SERVER_PASSWORD`) are Workers secrets on
874
+ Cloudflare — the full consumed-var list lives in the drift-guarded
875
+ `consumed-env-vars` block of `apps/cf-worker/wrangler.toml` (see
876
+ [Configuration vars & secrets](#configuration-vars-secrets-cloudflare)).
750
877
  Backends: D1 on Cloudflare, DynamoDB on AWS, in-memory for the
751
878
  local CLI / tests (all write-behind; registrations survive redeploys).
752
879
  When no store is bound, services commands reply `501` and the rest of
753
880
  the daemon is unaffected. See `docs/Services.md` for the full
754
881
  reference.
755
-
756
882
  **IRCv3 extensions (negotiated via `CAP`):** `message-tags` (incl. the
757
883
  `TAGMSG` command), `server-time`, `account-tag`, `account-notify`
758
884
  (pushes `ACCOUNT` on SASL login/logout), `echo-message`, `batch`,
759
- `sasl` (`PLAIN` always; `EXTERNAL` via mTLS when a client-cert trust store
760
- is bound), `multi-prefix`, `away-notify`, `chghost`, `invite-notify`,
885
+ `sasl` (`PLAIN` always; `EXTERNAL` only under the triple gate of
886
+ operator opt-in (`EXTERNAL_ENABLED`, default off) + a bound edge-mTLS
887
+ identity source + a TLS connection — accounts bind to the client
888
+ cert's DER SHA-256 fingerprint or a canonical subject DN, refusals
889
+ answer `908 ERR_SASLMECHS` listing `PLAIN` only, and the
890
+ `cf-tcp-container` Spectrum origin rejects `AUTHENTICATE EXTERNAL`
891
+ with a transport-specific `904`; see
892
+ [Abuse controls & credential hardening](#abuse-controls--credential-hardening)),
893
+ `multi-prefix`, `away-notify`, `chghost`, `invite-notify`,
761
894
  `extended-join`, `msgid` (`@+msgid=<id>` on every PRIVMSG/NOTICE/TAGMSG,
762
895
  shared between live and `draft/chathistory` replay), `standard-replies`
763
896
  (`FAIL`/`WARN`/`NOTE` replacements for a curated numeric subset),
@@ -69,6 +69,26 @@ const iUnderstandThisLeaksCredentials = app.node.tryGetContext('iUnderstandThisL
69
69
  const allowDataTraceBool = parseContextBool(allowDataTrace);
70
70
  const acknowledgeLeakBool = parseContextBool(iUnderstandThisLeaksCredentials);
71
71
 
72
+ // DynamoDB table-deletion escape hatch (state-table protection). Safe-by-
73
+ // default: omitted → the stack keeps state tables RETAIN + deletion-protected
74
+ // + PITR-on. `-c allowTableDeletion=true` flips state tables to DESTROY +
75
+ // deletion-protection OFF so a deliberate `cdk destroy` teardown can delete
76
+ // them (the data is irreversibly lost — DynamoDB tables do not snapshot on
77
+ // delete). `Connections` is always DESTROY regardless. See §9.4 / §11.
78
+ const allowTableDeletion = app.node.tryGetContext('allowTableDeletion') as
79
+ | boolean
80
+ | string
81
+ | undefined;
82
+ const allowTableDeletionBool = parseContextBool(allowTableDeletion);
83
+
84
+ // DynamoDB point-in-time recovery toggle. Defaults to ON (the stack's own
85
+ // default when omitted); `-c enablePitr=false` disables PITR on every state
86
+ // table (PITR is billed continuously at ~$0.20/GB-month per table; RETAIN
87
+ // and deletionProtection are free). `Connections` never carries PITR. The
88
+ // flag is independent of `allowTableDeletion` — a teardown keeps PITR on.
89
+ const enablePitr = app.node.tryGetContext('enablePitr') as boolean | string | undefined;
90
+ const enablePitrBool = parseContextBool(enablePitr);
91
+
72
92
  // Web client hosting (S3 + CloudFront + OAC) is opt-in. Supplying
73
93
  // `webSiteCustomDomain` provisions the static-site construct; prod passes
74
94
  // the custom domain + cert + hosted zone, staging leaves them unset to use
@@ -87,6 +107,19 @@ const webSite =
87
107
  }
88
108
  : undefined;
89
109
 
110
+ // Optional WAFv2 per-IP edge rate limit on the WebSocket API's `$connect`
111
+ // (requests per 5-minute window per client IP). Omitted → no WAF
112
+ // resources; `-c wafConnectRateLimit=2000` provisions the WebACL with a
113
+ // rate-based rule + the stage association. CDK context values arrive as
114
+ // strings, so parse to an integer and drop non-numeric values.
115
+ const rawWafConnectRateLimit = app.node.tryGetContext('wafConnectRateLimit');
116
+ const wafConnectRateLimit =
117
+ typeof rawWafConnectRateLimit === 'number'
118
+ ? rawWafConnectRateLimit
119
+ : typeof rawWafConnectRateLimit === 'string' && /^\d+$/u.test(rawWafConnectRateLimit.trim())
120
+ ? Number.parseInt(rawWafConnectRateLimit.trim(), 10)
121
+ : undefined;
122
+
90
123
  const stackProps: IrcStackProps = {
91
124
  ...(serverName !== undefined ? { serverName } : {}),
92
125
  ...(networkName !== undefined ? { networkName } : {}),
@@ -98,6 +131,9 @@ const stackProps: IrcStackProps = {
98
131
  ...(acknowledgeLeakBool !== undefined
99
132
  ? { iUnderstandThisLeaksCredentials: acknowledgeLeakBool }
100
133
  : {}),
134
+ ...(allowTableDeletionBool !== undefined ? { allowTableDeletion: allowTableDeletionBool } : {}),
135
+ ...(enablePitrBool !== undefined ? { enablePitr: enablePitrBool } : {}),
136
+ ...(wafConnectRateLimit !== undefined ? { wafConnectRateLimit } : {}),
101
137
  ...(account && region ? { env: { account, region } } : {}),
102
138
  };
103
139
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-stack",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "AWS CDK v2 stack: API Gateway v2 WebSocket API, Lambda, DynamoDB tables, least-privilege IAM",
6
6
  "license": "BSD-3-Clause",
@@ -34,7 +34,7 @@ import {
34
34
  } from 'aws-cdk-lib/aws-apigatewayv2';
35
35
  import { WebSocketLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
36
36
  import { Certificate } from 'aws-cdk-lib/aws-certificatemanager';
37
- import { Table } from 'aws-cdk-lib/aws-dynamodb';
37
+ import { Table, type TableProps } from 'aws-cdk-lib/aws-dynamodb';
38
38
  import { Vpc } from 'aws-cdk-lib/aws-ec2';
39
39
  import {
40
40
  NetworkListener,
@@ -50,6 +50,7 @@ import { PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
50
50
  import { Code, Function as Lambda, Runtime } from 'aws-cdk-lib/aws-lambda';
51
51
  import { NodejsFunction, type NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs';
52
52
  import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
53
+ import { CfnWebACL, CfnWebACLAssociation } from 'aws-cdk-lib/aws-wafv2';
53
54
  import type { Construct } from 'constructs';
54
55
  import { StaticSite, type StaticSiteProps } from './static-site.js';
55
56
 
@@ -61,7 +62,14 @@ const IRC_TLS_PORT = 6697;
61
62
 
62
63
  /** Fixed rate for the gone-connection sweeper (matches the architecture doc). */
63
64
  const SWEEPER_SCHEDULE_RATE = Duration.minutes(5);
64
-
65
+ /**
66
+ * Logical id of the one ephemeral DynamoDB table. `Connections` holds
67
+ * per-connection rows (TTL-reaped, sweeper-cleaned) and is the ONLY table
68
+ * kept on `RemovalPolicy.DESTROY` with no PITR and no deletion protection;
69
+ * every other table is state-bearing and protected (see the table-creation
70
+ * block below).
71
+ */
72
+ const EPHEMERAL_TABLE_LOGICAL_ID = 'Connections';
65
73
  /**
66
74
  * Fixed rate for the idle / PING checker. One minute is the finest
67
75
  * granularity EventBridge `rate(...)` expressions support, which
@@ -211,6 +219,65 @@ export interface IrcStackProps extends StackProps {
211
219
  * Set via CDK context: `-c iUnderstandThisLeaksCredentials=true`.
212
220
  */
213
221
  readonly iUnderstandThisLeaksCredentials?: boolean;
222
+
223
+ /**
224
+ * Escape hatch that allows `cdk destroy` to tear down the state-bearing
225
+ * DynamoDB tables (Nicks, Services, ChannelMeta, ChannelMembers).
226
+ *
227
+ * Defaults to `false` (safe): state tables are `RemovalPolicy.RETAIN`'d
228
+ * and carry `deletionProtection: true`, so a mistaken `cdk destroy`, a
229
+ * stack-name collision, or a stray `aws dynamodb delete-table` from a
230
+ * compromised credential CANNOT wipe user accounts. The Connections
231
+ * table is always DESTROY regardless (its rows are ephemeral per-
232
+ * connection records, TTL-reaped and sweeper-cleaned).
233
+ *
234
+ * Set to `true` ONLY for a deliberate teardown (`-c allowTableDeletion=true`):
235
+ * every state table flips to `RemovalPolicy.DESTROY` with
236
+ * `deletionProtection: false` so CloudFormation can delete them. The data
237
+ * is irreversibly lost — there is no DynamoDB snapshot-on-delete (tables
238
+ * do not snapshot); `RETAIN` + PITR is the only protection, so flipping
239
+ * this off is a one-way operation. {@link enablePitr} is independent and
240
+ * stays on by default even in teardown mode.
241
+ */
242
+ readonly allowTableDeletion?: boolean;
243
+
244
+ /**
245
+ * Toggles point-in-time recovery (PITR) on the state-bearing DynamoDB
246
+ * tables (Nicks, Services, ChannelMeta, ChannelMembers). Defaults to
247
+ * `true` — PITR enables continuous, per-table point-in-time restore
248
+ * (within the last ~35 days) against accidental writes or deletes that
249
+ * RETAIN + deletion protection do not cover (e.g. a buggy deploy that
250
+ * overwrites rows, or an `UpdateItem` with the wrong key).
251
+ *
252
+ * The Connections table NEVER carries PITR regardless of this flag: its
253
+ * rows are ephemeral and the restore cost is unjustified.
254
+ *
255
+ * Set `-c enablePitr=false` to disable on all tables. PITR is billed
256
+ * continuously at ~$0.20/GB-month per table (see docs/AWS-Deployment.md);
257
+ * RETAIN and deletionProtection are free.
258
+ */
259
+ readonly enablePitr?: boolean;
260
+
261
+ /**
262
+ * Optional WAFv2 per-IP edge rate limit for the WebSocket API's
263
+ * `$connect` route (requests per 5-minute window per client IP).
264
+ *
265
+ * When set, the stack emits a `AWS::WAFv2::WebACL` (REGIONAL scope,
266
+ * default action Allow) whose single rate-based rule BLOCKS an IP once
267
+ * it exceeds the limit, associated with the WebSocket stage. WAF on a
268
+ * WebSocket API only inspects the initial HTTP upgrade — i.e. the
269
+ * `$connect` route; established-connection frames ride the upgrade and
270
+ * are never re-inspected — so the rule is effectively scoped to
271
+ * connection establishment without any route matching.
272
+ *
273
+ * This is the EDGE tier of the three-layer connect throttling (WAF at
274
+ * the edge → APIGW stage throttling → the per-IP admission gates in
275
+ * the `$connect` handler); it blocks a flood before a Lambda is
276
+ * invoked at all. Unset (the default) provisions no WAF resources.
277
+ *
278
+ * Set via CDK context: `-c wafConnectRateLimit=2000`.
279
+ */
280
+ readonly wafConnectRateLimit?: number;
214
281
  }
215
282
 
216
283
  export class IrcAwsStack extends Stack {
@@ -268,6 +335,34 @@ export class IrcAwsStack extends Stack {
268
335
  default: 'INFO',
269
336
  allowedValues: ['OFF', 'INFO', 'ERROR'],
270
337
  });
338
+
339
+ // Stage-level throttling: the global backstop BEHIND the per-IP
340
+ // admission gates (the `sourceIp` GSI counts in the `$connect`
341
+ // handler and the per-IP rate knobs). Caps the aggregate
342
+ // requests/second (and burst) APIGW forwards to ANY route — a
343
+ // connect flood from many IPs hits this even when each single IP
344
+ // stays under its per-IP budget. Defaults (100 rps / burst 200) are
345
+ // generous for an IRC deployment sized by `maxClients`; tune at
346
+ // deploy time via `--parameters ApiThrottlingRateLimit=...`. For
347
+ // per-IP rate limiting at the EDGE, deploy a WAFv2 rate-based rule
348
+ // scoped to the `$connect` route (operator-side; see
349
+ // docs/AWS-Deployment.md § throttling).
350
+ const apiThrottlingRateParam = new CfnParameter(this, 'ApiThrottlingRateLimit', {
351
+ type: 'Number',
352
+ description:
353
+ 'API Gateway stage throttling: max requests per second across all routes ' +
354
+ '(global backstop behind the per-IP admission gates). ' +
355
+ 'Override at deploy time via `--parameters ApiThrottlingRateLimit=...`.',
356
+ default: 100,
357
+ });
358
+ const apiThrottlingBurstParam = new CfnParameter(this, 'ApiThrottlingBurstLimit', {
359
+ type: 'Number',
360
+ description:
361
+ 'API Gateway stage throttling: burst capacity (concurrent requests allowed momentarily) ' +
362
+ 'paired with ApiThrottlingRateLimit. ' +
363
+ 'Override at deploy time via `--parameters ApiThrottlingBurstLimit=...`.',
364
+ default: 200,
365
+ });
271
366
  const motd = (props.motdLines ?? DEFAULT_MOTD_LINES).join('\n');
272
367
 
273
368
  // EventBridge schedule expressions are surfaced as CloudFormation
@@ -302,13 +397,61 @@ export class IrcAwsStack extends Stack {
302
397
  // staging/prod split was collapsed); staging vs production isolation
303
398
  // is driven by which AWS account + region the deploy credentials
304
399
  // target, not by table-name prefixing.
400
+ //
401
+ // State-bearing tables (every table EXCEPT `Connections`) are protected
402
+ // from accidental data loss by THREE independent mechanisms, each
403
+ // toggleable so a deliberate teardown can still proceed:
404
+ // • `RemovalPolicy.RETAIN` — orphans the table (keeps its data) when
405
+ // the stack is deleted, instead of CloudFormation deleting it.
406
+ // • `deletionProtection: true` — DynamoDB refuses a `DeleteTable`
407
+ // API call while this is on, blocking a stray
408
+ // `aws dynamodb delete-table` even from an admin credential.
409
+ // • `pointInTimeRecovery` — continuous, per-table restore (within
410
+ // ~35 days) against accidental writes/deletes that RETAIN +
411
+ // deletion protection cannot stop (e.g. a buggy `UpdateItem`).
412
+ //
413
+ // NOTE: SNAPSHOT semantics are deliberately NOT used. DynamoDB tables
414
+ // do NOT snapshot on stack delete (`RemovalPolicy.SNAPSHOT` is a no-op
415
+ // here and would lull an operator into a false sense of backup). The
416
+ // correct combination is RETAIN (orphan + keep) + PITR (continuous,
417
+ // in-place restore). `Connections` is the sole exception: its rows are
418
+ // ephemeral per-connection records (TTL-reaped, sweeper-cleaned), so it
419
+ // stays DESTROY with no PITR and no deletion protection at all times.
420
+ //
421
+ // Cost: RETAIN and deletionProtection are free; PITR is billed
422
+ // continuously at ~$0.20/GB-month per table (see docs/AWS-Deployment.md).
423
+ const allowTableDeletion = props.allowTableDeletion === true;
424
+ const enablePitr = props.enablePitr !== false;
305
425
  const tables = Object.fromEntries(
306
- Object.entries(TABLE_DEFS).map(([logicalId, tableProps]) => {
426
+ Object.entries(TABLE_DEFS).map(([logicalId, tableDef]) => {
427
+ const isStateTable = logicalId !== EPHEMERAL_TABLE_LOGICAL_ID;
428
+ // `exactOptionalPropertyTypes` forbids passing `undefined` for
429
+ // optional props, so the protection knobs are spread in
430
+ // conditionally: Connections omits all three (always DESTROY,
431
+ // no PITR, no deletion protection); state tables set all three
432
+ // based on the flags.
433
+ const protectionProps: Partial<
434
+ Pick<TableProps, 'deletionProtection' | 'pointInTimeRecovery'>
435
+ > = isStateTable
436
+ ? {
437
+ deletionProtection: !allowTableDeletion,
438
+ pointInTimeRecovery: enablePitr,
439
+ }
440
+ : {};
441
+ // The GSI side-channel rides alongside the construct props in
442
+ // TABLE_DEFS; pop it before constructing (current CDK declares
443
+ // indexes via addGlobalSecondaryIndex, not TableProps).
444
+ const { globalSecondaryIndexes, ...tableProps } = tableDef;
307
445
  const table = new Table(this, logicalId, {
308
446
  ...tableProps,
309
447
  tableName: logicalId,
310
- removalPolicy: RemovalPolicy.DESTROY,
448
+ removalPolicy:
449
+ isStateTable && !allowTableDeletion ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
450
+ ...protectionProps,
311
451
  });
452
+ for (const gsi of globalSecondaryIndexes ?? []) {
453
+ table.addGlobalSecondaryIndex(gsi);
454
+ }
312
455
  return [logicalId, table];
313
456
  }),
314
457
  );
@@ -412,19 +555,25 @@ export class IrcAwsStack extends Stack {
412
555
  // APIGW validates this role when it is set as the account CloudWatch
413
556
  // role, and rejects one scoped to a single log-group ARN: execution
414
557
  // logs land in APIGW's OWN log groups (AWS/ApiGateway...), separate
415
- // from this stack's access-log group. Grant the logs write actions
416
- // across all log groups. Defined inline (not via the
417
- // AmazonAPIGatewayPushToCloudWatchLogs managed policy) because that
418
- // managed policy is not available in every partition.
558
+ // from this stack's access-log group. Grant ONLY the three write
559
+ // actions APIGW needs, across all log groups (the wildcard is an
560
+ // inherent APIGW constraint those group ARNs are not predictable at
561
+ // synth time). The legacy policy also carried Describe* actions; they
562
+ // were dropped so a compromised role cannot enumerate (or, given the
563
+ // read actions AWS's own managed policy for this purpose includes,
564
+ // exfiltrate) arbitrary log groups in the account.
565
+ //
566
+ // Managed-policy decision: the AWS-managed
567
+ // AmazonAPIGatewayPushToCloudWatchLogs policy was considered and
568
+ // REJECTED. Its fixed grant set is broader than needed — it grants
569
+ // DescribeLogGroups/DescribeLogStreams (deliberately dropped here)
570
+ // plus GetLogEvents/FilterLogEvents, all on '*' — and AWS can widen
571
+ // its contents without this stack being re-reviewed. It is also not
572
+ // available in every partition. The inline policy keeps the grant set
573
+ // exact and auditable at synth time.
419
574
  apiLoggingRole.addToPolicy(
420
575
  new PolicyStatement({
421
- actions: [
422
- 'logs:CreateLogGroup',
423
- 'logs:CreateLogStream',
424
- 'logs:DescribeLogGroups',
425
- 'logs:DescribeLogStreams',
426
- 'logs:PutLogEvents',
427
- ],
576
+ actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'],
428
577
  resources: ['*'],
429
578
  }),
430
579
  );
@@ -487,6 +636,10 @@ export class IrcAwsStack extends Stack {
487
636
  cfnStage.defaultRouteSettings = {
488
637
  loggingLevel: apiLoggingLevelParam.valueAsString,
489
638
  dataTraceEnabled,
639
+ // Stage throttling (see the parameter definitions above): global
640
+ // rate + burst backstop behind the per-IP admission gates.
641
+ throttlingRateLimit: apiThrottlingRateParam.valueAsNumber,
642
+ throttlingBurstLimit: apiThrottlingBurstParam.valueAsNumber,
490
643
  };
491
644
 
492
645
  stage.grantManagementApiAccess(handler);
@@ -494,6 +647,53 @@ export class IrcAwsStack extends Stack {
494
647
  handler.addEnvironment('MANAGEMENT_URL', stage.callbackUrl);
495
648
  pingChecker.addEnvironment('MANAGEMENT_URL', stage.callbackUrl);
496
649
 
650
+ // Optional WAFv2 rate-based rule on the WebSocket API's `$connect`
651
+ // (see the `wafConnectRateLimit` prop doc): WAF only inspects the
652
+ // initial HTTP upgrade on a WebSocket API, so the rate statement
653
+ // applies exactly where credential brute-force has to spend its
654
+ // budget — connection establishment — and never to established
655
+ // frames. Blocks happen at the edge, before a Lambda invocation is
656
+ // billed. Unset → no WAF resources at all (the default).
657
+ if (props.wafConnectRateLimit !== undefined) {
658
+ const webAcl = new CfnWebACL(this, 'ConnectRateWebAcl', {
659
+ scope: 'REGIONAL',
660
+ defaultAction: { allow: {} },
661
+ // CFN requires a VisibilityConfig on both the ACL and the rule;
662
+ // sampled requests + the CloudWatch metric keep operator
663
+ // visibility without full request logging.
664
+ visibilityConfig: {
665
+ cloudWatchMetricsEnabled: true,
666
+ metricName: 'IrcConnectWebAcl',
667
+ sampledRequestsEnabled: true,
668
+ },
669
+ rules: [
670
+ {
671
+ name: 'ConnectRateLimit',
672
+ priority: 0,
673
+ action: { block: {} },
674
+ visibilityConfig: {
675
+ cloudWatchMetricsEnabled: true,
676
+ metricName: 'IrcConnectRateLimit',
677
+ sampledRequestsEnabled: true,
678
+ },
679
+ statement: {
680
+ rateBasedStatement: {
681
+ limit: props.wafConnectRateLimit,
682
+ aggregateKeyType: 'IP',
683
+ },
684
+ },
685
+ },
686
+ ],
687
+ });
688
+ new CfnWebACLAssociation(this, 'ConnectRateWebAclAssociation', {
689
+ webAclArn: webAcl.attrArn,
690
+ // WAF associates with API Gateway stages via the v1-style stage
691
+ // ARN namespace (`/restapis/…`) — WebSocket APIs use the same
692
+ // format (there is no `WebSocketStage.stageArn` L2 helper).
693
+ resourceArn: `arn:${this.partition}:apigateway:${this.region}::/restapis/${webSocketApi.apiId}/stages/${stage.stageName}`,
694
+ });
695
+ }
696
+
497
697
  // Inject each table's physical name as an env var so every Lambda can
498
698
  // construct a `TablesConfig` at cold start via `buildDepsFromEnv`. The
499
699
  // env-var value is the literal physical table name (the bare logical
@@ -607,6 +807,12 @@ export class IrcAwsStack extends Stack {
607
807
  nlbHandler.addEnvironment('NETWORK_NAME', networkName);
608
808
  nlbHandler.addEnvironment('MOTD', motd);
609
809
  nlbHandler.addEnvironment('MANAGEMENT_URL', stage.callbackUrl);
810
+ // The NLB handler fans channel messages out to OTHER connections via
811
+ // the management API — including wss clients on the API Gateway
812
+ // transport — so it needs the same `execute-api:ManageConnections`
813
+ // grant as the wss handler and ping checker. Without it every
814
+ // cross-transport `postToConnection` fails with 403.
815
+ stage.grantManagementApiAccess(nlbHandler);
610
816
 
611
817
  const nlb = new NetworkLoadBalancer(this, 'IrcNlb', {
612
818
  vpc,