@hilbras/keystone 2.6.0 → 3.0.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 (122) hide show
  1. package/CHANGELOG.md +350 -0
  2. package/README.md +72 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +7 -1
  5. package/dist/config.js.map +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +15 -11
  8. package/dist/index.js.map +1 -1
  9. package/dist/plugins/rateLimit.d.ts +10 -7
  10. package/dist/plugins/rateLimit.d.ts.map +1 -1
  11. package/dist/plugins/rateLimit.js +75 -36
  12. package/dist/plugins/rateLimit.js.map +1 -1
  13. package/dist/routes/admin/organizations.d.ts.map +1 -1
  14. package/dist/routes/admin/organizations.js +2 -0
  15. package/dist/routes/admin/organizations.js.map +1 -1
  16. package/dist/routes/admin/platform.d.ts.map +1 -1
  17. package/dist/routes/admin/platform.js +14 -1
  18. package/dist/routes/admin/platform.js.map +1 -1
  19. package/dist/routes/apiKeys.d.ts.map +1 -1
  20. package/dist/routes/apiKeys.js +15 -1
  21. package/dist/routes/apiKeys.js.map +1 -1
  22. package/dist/routes/auth.d.ts.map +1 -1
  23. package/dist/routes/auth.js +104 -3
  24. package/dist/routes/auth.js.map +1 -1
  25. package/dist/routes/emailVerification.d.ts.map +1 -1
  26. package/dist/routes/emailVerification.js +2 -0
  27. package/dist/routes/emailVerification.js.map +1 -1
  28. package/dist/routes/magicLinks.d.ts.map +1 -1
  29. package/dist/routes/magicLinks.js +2 -0
  30. package/dist/routes/magicLinks.js.map +1 -1
  31. package/dist/routes/oauth2.d.ts.map +1 -1
  32. package/dist/routes/oauth2.js +16 -2
  33. package/dist/routes/oauth2.js.map +1 -1
  34. package/dist/routes/password.d.ts.map +1 -1
  35. package/dist/routes/password.js +2 -0
  36. package/dist/routes/password.js.map +1 -1
  37. package/dist/routes/scim.d.ts.map +1 -1
  38. package/dist/routes/scim.js +2 -0
  39. package/dist/routes/scim.js.map +1 -1
  40. package/dist/routes/smsOtp.d.ts.map +1 -1
  41. package/dist/routes/smsOtp.js +4 -0
  42. package/dist/routes/smsOtp.js.map +1 -1
  43. package/dist/routes/totp.d.ts.map +1 -1
  44. package/dist/routes/totp.js +18 -1
  45. package/dist/routes/totp.js.map +1 -1
  46. package/dist/services/configuration/profiles.d.ts +26 -0
  47. package/dist/services/configuration/profiles.d.ts.map +1 -1
  48. package/dist/services/configuration/profiles.js +80 -1
  49. package/dist/services/configuration/profiles.js.map +1 -1
  50. package/dist/services/events/subscribers/auditLog.d.ts.map +1 -1
  51. package/dist/services/events/subscribers/auditLog.js +38 -3
  52. package/dist/services/events/subscribers/auditLog.js.map +1 -1
  53. package/dist/services/events/types.d.ts +3 -1
  54. package/dist/services/events/types.d.ts.map +1 -1
  55. package/dist/services/events/validate.d.ts +1 -0
  56. package/dist/services/events/validate.d.ts.map +1 -1
  57. package/dist/services/events/validate.js +5 -1
  58. package/dist/services/events/validate.js.map +1 -1
  59. package/dist/services/localRateLimit.d.ts +44 -0
  60. package/dist/services/localRateLimit.d.ts.map +1 -0
  61. package/dist/services/localRateLimit.js +86 -0
  62. package/dist/services/localRateLimit.js.map +1 -0
  63. package/dist/services/refreshTokenState.d.ts +5 -0
  64. package/dist/services/refreshTokenState.d.ts.map +1 -0
  65. package/dist/services/refreshTokenState.js +30 -0
  66. package/dist/services/refreshTokenState.js.map +1 -0
  67. package/dist/services/setup/token.d.ts +12 -0
  68. package/dist/services/setup/token.d.ts.map +1 -1
  69. package/dist/services/setup/token.js +27 -3
  70. package/dist/services/setup/token.js.map +1 -1
  71. package/dist/services/tokens.d.ts +1 -0
  72. package/dist/services/tokens.d.ts.map +1 -1
  73. package/dist/services/tokens.js +1 -1
  74. package/dist/services/tokens.js.map +1 -1
  75. package/dist/services/trustedProxies.d.ts +24 -0
  76. package/dist/services/trustedProxies.d.ts.map +1 -1
  77. package/dist/services/trustedProxies.js +19 -0
  78. package/dist/services/trustedProxies.js.map +1 -1
  79. package/dist/services/webhooks.d.ts +16 -0
  80. package/dist/services/webhooks.d.ts.map +1 -1
  81. package/dist/services/webhooks.js +48 -3
  82. package/dist/services/webhooks.js.map +1 -1
  83. package/dist/setup-server.js +47 -3
  84. package/dist/setup-server.js.map +1 -1
  85. package/docs/API-REVIEW.md +121 -0
  86. package/docs/API.md +457 -0
  87. package/docs/ARCHITECTURE.md +142 -0
  88. package/docs/CONTRIBUTING.md +61 -0
  89. package/docs/DEPLOYMENT.md +257 -0
  90. package/docs/INTEGRATION.md +336 -0
  91. package/docs/LOGIN_FORM_INTEGRATION.md +306 -0
  92. package/docs/MIGRATION-1.7.md +70 -0
  93. package/docs/MIGRATION-1.8.md +183 -0
  94. package/docs/MIGRATION-1.9.md +200 -0
  95. package/docs/MIGRATION-2.0.md +203 -0
  96. package/docs/MIGRATION-2.4.md +185 -0
  97. package/docs/PERFORMANCE.md +155 -0
  98. package/docs/RBAC.md +100 -0
  99. package/docs/RE-AUDIT.md +72 -0
  100. package/docs/README.md +54 -0
  101. package/docs/RELEASE-1.7.md +53 -0
  102. package/docs/ROADMAP.md +41 -0
  103. package/docs/SECURITY.md +143 -0
  104. package/docs/adrs/001-identity-connectors-as-adapters.md +27 -0
  105. package/docs/adrs/002-versioned-event-bus.md +30 -0
  106. package/docs/adrs/003-bullmq-for-background-work.md +20 -0
  107. package/docs/adrs/004-argon2id-password-hashing.md +19 -0
  108. package/docs/plans/KEYSTONE_IMPROVEMENT_PLAN.md +334 -0
  109. package/docs/plans/UI_SIMPLIFICATION_IMPROVEMENT_PLAN.md +271 -0
  110. package/docs/security/audit.md +82 -0
  111. package/docs/security/configuration.md +60 -0
  112. package/docs/security/enterprise-sso.md +193 -0
  113. package/docs/security/mtls.md +132 -0
  114. package/docs/security/proxy-security.md +128 -0
  115. package/docs/security/rate-limiting.md +79 -0
  116. package/docs/security/registry-exceptions.md +34 -0
  117. package/docs/security/registry.json +649 -0
  118. package/docs/security/registry.md +657 -0
  119. package/docs/security/scopes.md +45 -0
  120. package/docs/security/supply-chain.md +49 -0
  121. package/docs/security/trust-boundaries.md +111 -0
  122. package/package.json +15 -6
@@ -0,0 +1,657 @@
1
+ # Security regression registry
2
+
3
+ <!-- Generated by scripts/render-security-registry.mjs from docs/security/registry.json. Do not edit by hand. -->
4
+
5
+ Every vulnerability found in Keystone, the fix, the test that would fail without it, and where it is documented. The registry is validated by `npm run registry:check`, which fails if an entry names a test that does not exist, if a security suite is claimed by no entry, or if a mandatory attack class is uncovered.
6
+
7
+ **46 findings.**
8
+
9
+ | Severity | Count |
10
+ | --- | --- |
11
+ | critical | 6 |
12
+ | high | 26 |
13
+ | medium | 13 |
14
+ | low | 1 |
15
+
16
+ ## Mandatory attack classes
17
+
18
+ The CI pipeline must exercise each of these. Every one is claimed by at least one entry above; the check is mechanical, not a convention.
19
+
20
+ | Attack class | Findings |
21
+ | --- | --- |
22
+ | `privilege-escalation` | SEC-001, SEC-002, SEC-003, SEC-007, SEC-023, SEC-025, SEC-028, SEC-032 |
23
+ | `mfa-bypass` | SEC-004, SEC-005, SEC-015, SEC-034 |
24
+ | `tenant-isolation` | SEC-001, SEC-003, SEC-006 |
25
+ | `token-replay` | SEC-012, SEC-013, SEC-038 |
26
+ | `oauth-attacks` | SEC-016, SEC-017, SEC-018, SEC-019, SEC-020, SEC-021 |
27
+ | `scim-cross-tenant` | SEC-006 |
28
+ | `mtls-spoofing` | SEC-007, SEC-009, SEC-010 |
29
+ | `xff-spoofing` | SEC-008, SEC-009 |
30
+ | `api-scope-escalation` | SEC-023, SEC-024, SEC-025 |
31
+ | `secret-disclosure` | SEC-026, SEC-027, SEC-028, SEC-029, SEC-030, SEC-031 |
32
+ | `session-persistence` | SEC-014, SEC-015, SEC-031, SEC-038 |
33
+ | `password-reset-invalidation` | SEC-014 |
34
+
35
+ ## Critical
36
+
37
+ ### SEC-001 — Organization role escalation through self-assignment
38
+
39
+ *Fixed in v1.7.0. Component: `authorization`.*
40
+
41
+ **Issue.** A member could set their own organization role, so a `member` could promote themselves to `owner` and then administer the organization they were only supposed to belong to.
42
+
43
+ **Fix.** src/routes/admin/organizations.ts — role changes are owner-only and cannot target the caller's own membership
44
+
45
+ **Test.** `src/tests/security/authorization/authorization.test.ts`
46
+
47
+ **Documentation.** [docs/security/trust-boundaries.md](./trust-boundaries.md)
48
+
49
+ **Attack classes.** `privilege-escalation`, `tenant-isolation`
50
+
51
+ ### SEC-002 — Platform-owner routes reachable without the owner role
52
+
53
+ *Fixed in v1.7.0. Component: `authorization`.*
54
+
55
+ **Issue.** Platform administration endpoints checked authentication but not the owner role, so any authenticated session could list and modify every user, organization and application in the installation.
56
+
57
+ **Fix.** src/routes/admin/helpers.ts — requireOwner() on every platform route
58
+
59
+ **Test.** `src/tests/security/authorization/authorization.test.ts`
60
+
61
+ **Documentation.** [docs/security/trust-boundaries.md](./trust-boundaries.md)
62
+
63
+ **Attack classes.** `privilege-escalation`
64
+
65
+ ### SEC-004 — MFA could be enabled without ever being enforced
66
+
67
+ *Fixed in v1.8.0. Component: `mfa`.*
68
+
69
+ **Issue.** MFA was checked on the interactive login path only. Every other token-issuing path — refresh, OAuth token exchange, the token login flow — issued a full session without consulting the user's MFA state, so the control was present in the UI and absent everywhere else.
70
+
71
+ **Fix.** src/services/application/authentication.ts — a single token-issuance chokepoint that every flow passes through
72
+
73
+ **Test.** `src/tests/security/mfa/mfa.test.ts`
74
+
75
+ **Documentation.** [docs/security/trust-boundaries.md](./trust-boundaries.md)
76
+
77
+ **Attack classes.** `mfa-bypass`
78
+
79
+ ### SEC-006 — SCIM credentials were not organization-scoped
80
+
81
+ *Fixed in v1.9.0. Component: `scim`.*
82
+
83
+ **Issue.** A SCIM token was accepted as a global credential, so a provisioning client for one organization could read and write users and groups in every organization in the installation.
84
+
85
+ **Fix.** src/services/scimCredentials.ts and src/routes/scim.ts — a credential resolves to exactly one organization
86
+
87
+ **Test.** `src/tests/security/scim/isolation.test.ts`
88
+
89
+ **Documentation.** [docs/security/trust-boundaries.md](./trust-boundaries.md)
90
+
91
+ **Attack classes.** `scim-cross-tenant`, `tenant-isolation`
92
+
93
+ ### SEC-007 — x-service-account-id was a complete authentication bypass
94
+
95
+ *Fixed in v2.0.0. Component: `trust-boundary`.*
96
+
97
+ **Issue.** The mTLS plugin trusted a client-supplied `x-service-account-id` header as the caller's identity. Any client that could reach the service could assert any service account and act as it, with no certificate and no secret.
98
+
99
+ **Fix.** src/plugins/mtls.ts — identity is derived from the verified client certificate and bound to its fingerprint
100
+
101
+ **Test.** `src/tests/security/proxy/trust-boundary.test.ts`
102
+
103
+ **Documentation.** [docs/security/mtls.md](./mtls.md)
104
+
105
+ **Attack classes.** `mtls-spoofing`, `privilege-escalation`
106
+
107
+ ### SEC-023 — A wildcard scope let any API key act as any service account
108
+
109
+ *Fixed in v2.6.0. Component: `api-keys`.*
110
+
111
+ **Issue.** The scope registry contained a `service_account` entry that any key carrying could use as a wildcard against every scope check, so a narrowly scoped key behaved as a fully privileged one.
112
+
113
+ **Fix.** src/services/scopes.ts — the wildcard is gone; every scope must be named
114
+
115
+ **Test.** `src/tests/security/api-keys/scopes-and-principals.test.ts`
116
+
117
+ **Documentation.** [docs/security/scopes.md](./scopes.md)
118
+
119
+ **Attack classes.** `api-scope-escalation`, `privilege-escalation`
120
+
121
+ ## High
122
+
123
+ ### SEC-003 — Tenant-unsafe workflow execution
124
+
125
+ *Fixed in v1.7.0. Component: `authorization`.*
126
+
127
+ **Issue.** Workflow lookup was keyed by identifier alone, so a request naming another organization's workflow run executed it and read its output.
128
+
129
+ **Fix.** src/services/workflows/engine.ts — every lookup is scoped to the owning organization
130
+
131
+ **Test.** `src/tests/security/authorization/authorization.test.ts`
132
+
133
+ **Documentation.** [docs/security/trust-boundaries.md](./trust-boundaries.md)
134
+
135
+ **Attack classes.** `tenant-isolation`, `privilege-escalation`
136
+
137
+ ### SEC-005 — TOTP verification was not bound to the user
138
+
139
+ *Fixed in v1.8.0. Component: `mfa`.*
140
+
141
+ **Issue.** A TOTP code was checked against a resolved secret without confirming the code belonged to the account being authenticated, so a valid code for one account could satisfy verification for another.
142
+
143
+ **Fix.** src/services/totp.ts — verification is user-scoped
144
+
145
+ **Test.** `src/tests/security/mfa/mfa.test.ts`
146
+
147
+ **Documentation.** [docs/security/trust-boundaries.md](./trust-boundaries.md)
148
+
149
+ **Attack classes.** `mfa-bypass`
150
+
151
+ ### SEC-008 — Unconditional x-forwarded-for trust escaped every rate limit
152
+
153
+ *Fixed in v2.0.0. Component: `proxy`.*
154
+
155
+ **Issue.** `trustProxy: true` accepted x-forwarded-for from any peer, so a client could present a fresh address on every request and receive an unlimited rate-limit budget on every endpoint, including login and MFA verification.
156
+
157
+ **Fix.** src/services/trustedProxies.ts — forwarding headers are honoured only from explicitly configured networks
158
+
159
+ **Test.** `src/tests/security/rate-limiting/abuse-prevention.test.ts`
160
+
161
+ **Documentation.** [docs/security/proxy-security.md](./proxy-security.md)
162
+
163
+ **Attack classes.** `xff-spoofing`
164
+
165
+ ### SEC-009 — Client-supplied identity headers were not stripped
166
+
167
+ *Fixed in v2.0.0. Component: `trust-boundary`.*
168
+
169
+ **Issue.** Identity-bearing headers survived to route handlers even when the peer was untrusted, so a header-stripping gap in one plugin would be a bypass in another.
170
+
171
+ **Fix.** src/plugins/headerSanitization.ts — untrusted identity headers are removed before routing
172
+
173
+ **Test.** `src/tests/security/proxy/trust-boundary.test.ts`
174
+
175
+ **Documentation.** [docs/security/trust-boundaries.md](./trust-boundaries.md)
176
+
177
+ **Attack classes.** `xff-spoofing`, `mtls-spoofing`
178
+
179
+ ### SEC-010 — A service account certificate was not bound to the account it claimed
180
+
181
+ *Fixed in v2.0.0. Component: `mtls`.*
182
+
183
+ **Issue.** Certificate authentication proved possession of a valid certificate but not that the certificate belonged to the service account being used, so any certificate issued by a trusted CA could impersonate any service account.
184
+
185
+ **Fix.** src/plugins/mtls.ts — the certificate fingerprint must match the stored binding
186
+
187
+ **Test.** `src/tests/security/proxy/trust-boundary.test.ts`
188
+
189
+ **Documentation.** [docs/security/mtls.md](./mtls.md)
190
+
191
+ **Attack classes.** `mtls-spoofing`
192
+
193
+ ### SEC-011 — A production container shipped a bundled npm with eight high-severity CVEs
194
+
195
+ *Fixed in v2.1.0. Component: `supply-chain`.*
196
+
197
+ **Issue.** The production image included npm itself, which carried eight high advisories. `npm audit` and the OSV scanner both read the lockfile, not the image, so the finding was invisible to every configured gate.
198
+
199
+ **Fix.** Dockerfile — npm is removed from the runtime stage
200
+
201
+ **Test.** `.github/workflows/supply-chain.yml`
202
+
203
+ **Documentation.** [docs/security/supply-chain.md](./supply-chain.md)
204
+
205
+ ### SEC-012 — Single-use credentials were readable and consumable more than once
206
+
207
+ *Fixed in v2.2.0. Component: `tokens`.*
208
+
209
+ **Issue.** Magic links, password reset tokens and SMS OTP codes were checked and then consumed as two separate steps. Concurrent requests could each pass the check before any of them consumed the credential, so a single emailed code produced many sessions — 50 concurrent requests yielded 50 successful logins.
210
+
211
+ **Fix.** src/services/singleUse.ts — a single atomic claim, so exactly one request can consume a credential
212
+
213
+ **Test.** `src/tests/security/tokens/single-use.test.ts`
214
+
215
+ **Documentation.** [docs/security/trust-boundaries.md](./trust-boundaries.md)
216
+
217
+ **Attack classes.** `token-replay`
218
+
219
+ ### SEC-014 — Password reset did not revoke existing sessions
220
+
221
+ *Fixed in v2.3.0. Component: `sessions`.*
222
+
223
+ **Issue.** Resetting a password left every existing session and refresh token valid. An attacker who had obtained a session before the victim changed their password retained access indefinitely, which defeats the purpose of the reset.
224
+
225
+ **Fix.** src/services/sessionRevocation.ts — a password change evicts sessions, refresh tokens and pending challenges
226
+
227
+ **Test.** `src/tests/security/sessions/session-revocation.test.ts`
228
+
229
+ **Documentation.** [docs/security/trust-boundaries.md](./trust-boundaries.md)
230
+
231
+ **Attack classes.** `password-reset-invalidation`, `session-persistence`
232
+
233
+ ### SEC-015 — Enabling MFA did not invalidate sessions issued without it
234
+
235
+ *Fixed in v2.3.0. Component: `sessions`.*
236
+
237
+ **Issue.** Turning on a second factor left pre-existing sessions untouched, so an attacker holding a session captured before enrolment kept full access after the user believed they had raised their security.
238
+
239
+ **Fix.** src/services/sessionRevocation.ts — revocation is centralized and applied on MFA enablement
240
+
241
+ **Test.** `src/tests/security/mfa/mfa.test.ts`
242
+
243
+ **Documentation.** [docs/security/trust-boundaries.md](./trust-boundaries.md)
244
+
245
+ **Attack classes.** `session-persistence`, `mfa-bypass`
246
+
247
+ ### SEC-016 — The authorization_code grant never verified the client secret
248
+
249
+ *Fixed in v2.4.0. Component: `oauth`.*
250
+
251
+ **Issue.** `verifyClientSecret` was called on the other grants but not on the authorization code exchange, so a confidential client could redeem a code with only its public client_id. Latent rather than live — the code still required a matching redirect_uri and the code itself — but it removed a layer that the grant was specified to have.
252
+
253
+ **Fix.** src/routes/oauth2.ts — client authentication is mandatory for confidential clients on every grant
254
+
255
+ **Test.** `src/tests/security/oauth/oauth2-hardening.test.ts`
256
+
257
+ **Documentation.** [docs/MIGRATION-2.4.md](./docs/MIGRATION-2.4.md)
258
+
259
+ **Attack classes.** `oauth-attacks`
260
+
261
+ ### SEC-017 — javascript: and data: were accepted as redirect URIs
262
+
263
+ *Fixed in v2.4.0. Component: `oauth`.*
264
+
265
+ **Issue.** Redirect URI validation checked structure but not scheme, so a client could register a `javascript:` URI. The authorization response carrying a code or token would then execute in the context of the authorization page, turning the redirect into script execution.
266
+
267
+ **Fix.** src/services/redirectUri.ts — only https, and http for loopback, are registrable
268
+
269
+ **Test.** `src/tests/security/oauth/oauth2-hardening.test.ts`
270
+
271
+ **Documentation.** [docs/MIGRATION-2.4.md](./docs/MIGRATION-2.4.md)
272
+
273
+ **Attack classes.** `oauth-attacks`
274
+
275
+ ### SEC-019 — ID token algorithm was inferred and expiry was optional
276
+
277
+ *Fixed in v2.4.0. Component: `oidc`.*
278
+
279
+ **Issue.** The ID token's signing algorithm was read from the token's own header rather than from the connector's registered configuration, and neither `exp` nor `iat` was required. A token could therefore be verified under an algorithm the operator never configured, and one with no expiry at all was accepted.
280
+
281
+ **Fix.** src/services/connectors/oidc.ts — the algorithm comes from configuration, and exp and iat are required
282
+
283
+ **Test.** `src/tests/security/oauth/oauth2-hardening.test.ts`
284
+
285
+ **Documentation.** [docs/MIGRATION-2.4.md](./docs/MIGRATION-2.4.md)
286
+
287
+ **Attack classes.** `oauth-attacks`
288
+
289
+ ### SEC-020 — The default Google connector discarded the OIDC nonce
290
+
291
+ *Fixed in v2.5.0. Component: `oidc`.*
292
+
293
+ **Issue.** `GoogleConnector.exchangeCode` dropped the nonce when calling the token endpoint, so a correct nonce was never sent and the response could not be checked. This defeated SEC-018 for the most commonly configured provider, which is why the fix in 2.4.0 was not sufficient on its own.
294
+
295
+ **Fix.** src/services/connectors/google.ts — the nonce is forwarded on exchange
296
+
297
+ **Test.** `src/tests/security/oidc/enterprise-sso.test.ts`
298
+
299
+ **Documentation.** [docs/security/enterprise-sso.md](./enterprise-sso.md)
300
+
301
+ **Attack classes.** `oauth-attacks`
302
+
303
+ ### SEC-024 — Scope enforcement failed open for requests without a key
304
+
305
+ *Fixed in v2.6.0. Component: `api-keys`.*
306
+
307
+ **Issue.** `requireScopes` returned early when `request.apiKeyId` was absent, so any authenticated path that reached a scoped route without going through key authentication skipped the scope check entirely.
308
+
309
+ **Fix.** src/services/scopes.ts — enforcement fails closed and treats a missing key as no authority
310
+
311
+ **Test.** `src/tests/security/api-keys/scopes-and-principals.test.ts`
312
+
313
+ **Documentation.** [docs/security/scopes.md](./scopes.md)
314
+
315
+ **Attack classes.** `api-scope-escalation`
316
+
317
+ ### SEC-025 — Human-only operations were available to machine principals
318
+
319
+ *Fixed in v2.6.0. Component: `service-accounts`.*
320
+
321
+ **Issue.** Profile and MFA management accepted any authenticated principal, so a service account key could read and change a person's profile and second-factor settings.
322
+
323
+ **Fix.** src/plugins/machinePrincipal.ts — requireHumanPrincipal() rejects machine principals, placed after authentication so it can see them
324
+
325
+ **Test.** `src/tests/security/api-keys/scopes-and-principals.test.ts`
326
+
327
+ **Documentation.** [docs/security/scopes.md](./scopes.md)
328
+
329
+ **Attack classes.** `api-scope-escalation`, `privilege-escalation`
330
+
331
+ ### SEC-026 — Configuration redaction used a denylist that missed half the secrets
332
+
333
+ *Fixed in v2.7.0. Component: `configuration`.*
334
+
335
+ **Issue.** Redaction matched a list of known secret-looking key names. Measuring it against the real configuration surface, 12 of 24 secret-shaped keys were returned unredacted, including the signing keys — so the owner-only configuration endpoint disclosed the material used to sign tokens.
336
+
337
+ **Fix.** src/services/configuration/profiles.ts — an allowlist of exposable keys replaces the denylist
338
+
339
+ **Test.** `src/tests/security/configuration/secrets-config.test.ts`
340
+
341
+ **Documentation.** [docs/security/configuration.md](./configuration.md)
342
+
343
+ **Attack classes.** `secret-disclosure`
344
+
345
+ ### SEC-027 — An empty CORS allowlist permitted every origin with credentials
346
+
347
+ *Fixed in v2.7.0. Component: `configuration`.*
348
+
349
+ **Issue.** An unset or empty `ALLOWED_ORIGINS` was treated as 'allow all', combined with credentialed requests. A misconfiguration therefore produced a wildcard CORS policy that browsers enforce, rather than a closed one.
350
+
351
+ **Fix.** src/services/trustedProxies.ts — isOriginAllowed() fails closed on an empty allowlist, shared with the tests rather than duplicated
352
+
353
+ **Test.** `src/tests/security/configuration/secrets-config.test.ts`
354
+
355
+ **Documentation.** [docs/security/configuration.md](./configuration.md)
356
+
357
+ **Attack classes.** `secret-disclosure`
358
+
359
+ ### SEC-028 — The setup server allowed any origin with credentials
360
+
361
+ *Fixed in v2.7.0. Component: `configuration`.*
362
+
363
+ **Issue.** The first-run setup server was configured with `origin: true` and credentials enabled, so any page in the operator's browser could call it during setup and complete provisioning.
364
+
365
+ **Fix.** src/routes/setup.ts — the setup origin is restricted to the server's own address
366
+
367
+ **Test.** `src/tests/security/configuration/secrets-config.test.ts`
368
+
369
+ **Documentation.** [docs/security/configuration.md](./configuration.md)
370
+
371
+ **Attack classes.** `secret-disclosure`, `privilege-escalation`
372
+
373
+ ### SEC-029 — The setup token was written to stdout
374
+
375
+ *Fixed in v2.7.0. Component: `configuration`.*
376
+
377
+ **Issue.** The token granting initial owner access was printed to the process log, so it reached log aggregation, container stdout capture and any log shipper — the credential intended to bootstrap trust was the one most widely distributed.
378
+
379
+ **Fix.** src/services/setup/token.ts — the token is delivered once, and printing requires an explicit opt-in
380
+
381
+ **Test.** `src/tests/security/configuration/secrets-config.test.ts`
382
+
383
+ **Documentation.** [docs/security/configuration.md](./configuration.md)
384
+
385
+ **Attack classes.** `secret-disclosure`
386
+
387
+ ### SEC-031 — Session cookies were not Secure by default in production
388
+
389
+ *Fixed in v2.7.0. Component: `configuration`.*
390
+
391
+ **Issue.** `COOKIE_SECURE` defaulted to false, so a production deployment that did not set it explicitly issued session cookies over plaintext, exposing them to interception on any non-TLS path.
392
+
393
+ **Fix.** src/plugins/auth.ts — Secure defaults to true when NODE_ENV is production
394
+
395
+ **Test.** `src/tests/security/configuration/secrets-config.test.ts`
396
+
397
+ **Documentation.** [docs/security/configuration.md](./configuration.md)
398
+
399
+ **Attack classes.** `secret-disclosure`, `session-persistence`
400
+
401
+ ### SEC-032 — The setup server listened on all interfaces
402
+
403
+ *Fixed in v2.7.0. Component: `configuration`.*
404
+
405
+ **Issue.** The setup server bound 0.0.0.0, so during first run the unauthenticated provisioning endpoint was reachable from the network rather than only from the operator's machine.
406
+
407
+ **Fix.** src/routes/setup.ts — loopback by default, overridable, and warns when exposed
408
+
409
+ **Test.** `src/tests/security/configuration/secrets-config.test.ts`
410
+
411
+ **Documentation.** [docs/security/configuration.md](./configuration.md)
412
+
413
+ **Attack classes.** `privilege-escalation`
414
+
415
+ ### SEC-033 — A Redis outage removed rate limiting entirely
416
+
417
+ *Fixed in v2.8.0. Component: `rate-limiting`.*
418
+
419
+ **Issue.** Every limiter returned 'allowed' when Redis was unavailable, so during an outage login, MFA verification, OTP verification and the OAuth token exchange had no limit at all. An outage is precisely when unlimited attempts are worth having, because a burst of guessing no longer looks like a burst.
420
+
421
+ **Fix.** src/services/localRateLimit.ts — a bounded in-process budget, used only when Redis is unavailable
422
+
423
+ **Test.** `src/tests/security/rate-limiting/abuse-prevention.test.ts`
424
+
425
+ **Documentation.** [docs/security/rate-limiting.md](./rate-limiting.md)
426
+
427
+ ### SEC-034 — MFA verification shared one budget across every user behind an address
428
+
429
+ *Fixed in v2.8.0. Component: `rate-limiting`.*
430
+
431
+ **Issue.** The rate-limit key included the submitted email, which the verification endpoint does not carry, so all 20 attempts were shared across every user at that address. An office behind a single NAT could have every legitimate second-factor login locked out by ordinary traffic.
432
+
433
+ **Fix.** src/routes/auth.ts — the key includes the challenge, which identifies one login attempt
434
+
435
+ **Test.** `src/tests/security/rate-limiting/abuse-prevention.test.ts`
436
+
437
+ **Documentation.** [docs/security/rate-limiting.md](./rate-limiting.md)
438
+
439
+ **Attack classes.** `mfa-bypass`
440
+
441
+ ### SEC-042 — The test runner's directory glob was shallower than the test tree
442
+
443
+ *Fixed in v2.9.0. Component: `testing`.*
444
+
445
+ **Issue.** `node --test` expands `**` as a single directory level rather than as globstar, so the discovery patterns stopped matching as soon as the security suites gained a directory level. 48 security tests — the entire authorization suite — stopped running, and the suite still reported success because the remaining files passed. A green run was reporting on less than the repository contained.
446
+
447
+ **Fix.** package.json — one pattern per directory depth, with no overlap
448
+
449
+ **Test.** `src/tests/security/registry.test.ts`
450
+
451
+ **Documentation.** [docs/security/registry.md](./registry.md)
452
+
453
+ ### SEC-043 — SAML assertions were validated without a complete issuer and audience binding
454
+
455
+ *Fixed in v2.5.0. Component: `saml`.*
456
+
457
+ **Issue.** The response validator checked signature and conditions but did not require a complete binding between the assertion and the configured service-provider entity, so a valid signed assertion minted for a different relying party could be accepted here.
458
+
459
+ **Fix.** src/services/saml/validator.ts — the audience and issuer are both required and must match configuration
460
+
461
+ **Test.** `src/tests/security/saml/saml-validator.test.ts`
462
+
463
+ **Documentation.** [docs/security/enterprise-sso.md](./enterprise-sso.md)
464
+
465
+ ### SEC-046 — Every service-account request produced no audit record at all
466
+
467
+ *Fixed in v2.9.0. Component: `audit`.*
468
+
469
+ **Issue.** A machine principal is represented in memory by a user object whose id is the sentinel `sa:<uuid>`, so that routes expecting `request.user` keep working without a matching user row. The audit subscriber passed that sentinel straight into `audit_log.user_id`, which is a uuid column. Postgres rejected the insert, the subscriber's catch logged `failed to write event` and the audit record was lost. The request itself succeeded, so nothing failed visibly, and the absence of a record looked exactly like a request that never happened. The practical effect was that every request authenticated by an API key or an mTLS service account left no audit trail — the privileged, non-human path, which is the one an attacker would most want to use quietly. Surfaced by a test whose fixture failed in CI and not locally, because CI's request ids and principal resolution differed enough for the bad insert to occur there.
470
+
471
+ **Fix.** src/services/events/subscribers/auditLog.ts — the sentinel is stripped, user_id is left null, and the service account is recorded in metadata
472
+
473
+ **Test.** `src/tests/security/service-accounts/audit-attribution.test.ts`
474
+
475
+ **Documentation.** [docs/security/audit.md](./audit.md)
476
+
477
+ ## Medium
478
+
479
+ ### SEC-013 — A used credential was not distinguishable from an unknown one
480
+
481
+ *Fixed in v2.2.0. Component: `tokens`.*
482
+
483
+ **Issue.** Replay of a consumed magic link, reset token or OTP returned the same error as presenting a credential that never existed, so the second use of a stolen code was invisible.
484
+
485
+ **Fix.** src/services/singleUse.ts — replay is detected and reported as its own outcome
486
+
487
+ **Test.** `src/tests/security/tokens/single-use.test.ts`
488
+
489
+ **Documentation.** [docs/security/trust-boundaries.md](./trust-boundaries.md)
490
+
491
+ **Attack classes.** `token-replay`
492
+
493
+ ### SEC-018 — OIDC authorization requests carried no nonce
494
+
495
+ *Fixed in v2.4.0. Component: `oidc`.*
496
+
497
+ **Issue.** The authorization request generated no nonce and the ID token response was not checked for one, so a token minted for a different session could be replayed into this one.
498
+
499
+ **Fix.** src/routes/oauth2.ts — a nonce is generated per request and verified on the response
500
+
501
+ **Test.** `src/tests/security/oauth/oauth2-hardening.test.ts`
502
+
503
+ **Documentation.** [docs/MIGRATION-2.4.md](./docs/MIGRATION-2.4.md)
504
+
505
+ **Attack classes.** `oauth-attacks`
506
+
507
+ ### SEC-021 — An unsigned SAML Issuer element was not validated
508
+
509
+ *Fixed in v2.5.0. Component: `saml`.*
510
+
511
+ **Issue.** The Issuer was compared only when it fell inside a signed region. An attacker could place an unsigned Issuer naming a trusted IdP alongside a signed assertion from another IdP. Confirmed to lie outside both signed regions, so it was a real gap in the trust decision rather than a theoretical one.
512
+
513
+ **Fix.** src/services/saml/validator.ts — the Issuer is validated against the configured entity id in all cases
514
+
515
+ **Test.** `src/tests/security/saml/saml-adversarial.test.ts`
516
+
517
+ **Documentation.** [docs/security/enterprise-sso.md](./enterprise-sso.md)
518
+
519
+ **Attack classes.** `oauth-attacks`
520
+
521
+ ### SEC-022 — RelayState verification threw instead of returning false
522
+
523
+ *Fixed in v2.5.0. Component: `saml`.*
524
+
525
+ **Issue.** A RelayState that failed its integrity check raised rather than returning a rejection, turning a routine validation failure into a 500 and obscuring the real cause.
526
+
527
+ **Fix.** src/services/saml/relayState.ts — verification returns a boolean and never throws
528
+
529
+ **Test.** `src/tests/security/saml/saml-adversarial.test.ts`
530
+
531
+ **Documentation.** [docs/security/enterprise-sso.md](./enterprise-sso.md)
532
+
533
+ ### SEC-030 — Webhook signing secrets were stored in plaintext
534
+
535
+ *Fixed in v2.7.0. Component: `secrets`.*
536
+
537
+ **Issue.** Webhook secrets were stored as issued, so a database read, a backup or an admin query returned material that lets an attacker forge delivery attempts signed as this installation.
538
+
539
+ **Fix.** src/services/webhooks.ts — AES-256-GCM at rest, with legacy plaintext still readable
540
+
541
+ **Test.** `src/tests/security/configuration/secrets-config.test.ts`
542
+
543
+ **Documentation.** [docs/security/configuration.md](./configuration.md)
544
+
545
+ **Attack classes.** `secret-disclosure`
546
+
547
+ ### SEC-035 — Credential spraying was unbounded
548
+
549
+ *Fixed in v2.8.0. Component: `rate-limiting`.*
550
+
551
+ **Issue.** The login budget was keyed on address and submitted address together, so it stopped repeated guesses at one account and did nothing about an attacker varying the address across a thousand accounts from one host.
552
+
553
+ **Fix.** src/routes/auth.ts — a second, address-keyed budget bounds spraying independently
554
+
555
+ **Test.** `src/tests/security/rate-limiting/abuse-prevention.test.ts`
556
+
557
+ **Documentation.** [docs/security/rate-limiting.md](./rate-limiting.md)
558
+
559
+ ### SEC-036 — A refused request left no record
560
+
561
+ *Fixed in v2.8.0. Component: `audit`.*
562
+
563
+ **Issue.** A rate-limit trip produced a 429 and nothing else, so sustained guessing at login or MFA verification was invisible except in aggregate. The requests that most warranted attention were the only ones absent from the log.
564
+
565
+ **Fix.** src/plugins/rateLimit.ts — rate_limit_triggered records the endpoint, address and which limiter decided
566
+
567
+ **Test.** `src/tests/security/rate-limiting/abuse-prevention.test.ts`
568
+
569
+ **Documentation.** [docs/security/rate-limiting.md](./rate-limiting.md)
570
+
571
+ ### SEC-037 — Failed logins were never audited
572
+
573
+ *Fixed in v2.8.0. Component: `audit`.*
574
+
575
+ **Issue.** user_login_failed existed in the event vocabulary and was never emitted, on either login route. Password guessing left no record.
576
+
577
+ **Fix.** src/routes/auth.ts — emitted on both routes, with no user id since the submitted address may match no account
578
+
579
+ **Test.** `src/tests/security/authentication/login-abuse.test.ts`
580
+
581
+ **Documentation.** [docs/security/audit.md](./audit.md)
582
+
583
+ ### SEC-038 — A replayed refresh token was indistinguishable from an unknown one
584
+
585
+ *Fixed in v2.8.0. Component: `tokens`.*
586
+
587
+ **Issue.** Rotation consumes a refresh token, so a second presentation failed exactly as a token that never existed would. A leaked token used twice was therefore treated as a typo, and the account's other credentials stayed valid.
588
+
589
+ **Fix.** src/services/refreshTokenState.ts — a spent token is identified and the account's credentials are revoked
590
+
591
+ **Test.** `src/tests/security/authentication/login-abuse.test.ts`
592
+
593
+ **Documentation.** [docs/security/audit.md](./audit.md)
594
+
595
+ **Attack classes.** `token-replay`, `session-persistence`
596
+
597
+ ### SEC-039 — API key creation had no rate limit
598
+
599
+ *Fixed in v2.8.0. Component: `api-keys`.*
600
+
601
+ **Issue.** Minting a credential is an authentication event but was unlimited, so a leaked session could enumerate a batch of keys.
602
+
603
+ **Fix.** src/routes/apiKeys.ts — bounded like any other authentication endpoint
604
+
605
+ **Test.** `src/tests/security/rate-limiting/abuse-prevention.test.ts`
606
+
607
+ **Documentation.** [docs/security/rate-limiting.md](./rate-limiting.md)
608
+
609
+ ### SEC-040 — A stale build output kept deleted security tests running
610
+
611
+ *Fixed in v2.9.0. Component: `testing`.*
612
+
613
+ **Issue.** `tsc` does not remove output for sources that were renamed or deleted, so a moved suite ran twice under two paths and a deleted suite kept running. Test discovery therefore did not reflect the source tree, and a security test could be removed from the repository while continuing to pass in CI — or the reverse, a suite silently dropped out of the run.
614
+
615
+ **Fix.** package.json — the build cleans dist before compiling
616
+
617
+ **Test.** `scripts/verify-release-metadata.mjs`
618
+
619
+ **Documentation.** [docs/security/registry.md](./registry.md)
620
+
621
+ ### SEC-044 — The SSO endpoint could be resolved through a name the operator did not register
622
+
623
+ *Fixed in v2.5.0. Component: `saml`.*
624
+
625
+ **Issue.** The endpoint that initiates SSO was addressable through a host alias that was never in the connection's registered endpoints, so a connection could be initiated against a name outside the configuration an operator reviewed.
626
+
627
+ **Fix.** src/routes/sso.ts — the request address must be one the connection registered
628
+
629
+ **Test.** `src/tests/security/saml/sso-endpoint.test.ts`
630
+
631
+ **Documentation.** [docs/security/enterprise-sso.md](./enterprise-sso.md)
632
+
633
+ ### SEC-045 — The audit log export did not neutralise spreadsheet formula injection
634
+
635
+ *Fixed in v2.9.0. Component: `audit`.*
636
+
637
+ **Issue.** The CSV export quoted values containing a delimiter or a quote, but did not neutralise a value beginning with `=`, `+`, `-` or `@`. Several of the exported columns are attacker-supplied — the user agent above all — and a spreadsheet evaluates such a cell as a formula when the file is opened. An audit export is precisely the file an operator opens in a spreadsheet, so that is the expected consumer rather than an edge case. Found by Semgrep's `direct-response-write` rule during the triage for this release; the rule's own finding was a false positive, but the code it pointed at was not.
638
+
639
+ **Fix.** src/routes/admin/platform.ts — a value with a formula prefix in first position is prefixed with an apostrophe before quoting
640
+
641
+ **Test.** `src/tests/security/audit-export.test.ts`
642
+
643
+ **Documentation.** [docs/security/audit.md](./audit.md)
644
+
645
+ ## Low
646
+
647
+ ### SEC-041 — A test fixture path was correct at only one directory depth
648
+
649
+ *Fixed in v2.9.0. Component: `testing`.*
650
+
651
+ **Issue.** A suite reached its fixture by counting parent directories, which resolved correctly from `src` and not from the compiled output in `dist`. The suite passed and then failed to load the moment it was moved, which is a poor way to discover that a path is fragile.
652
+
653
+ **Fix.** src/tests/helpers/paths.ts — paths are anchored on the nearest package.json
654
+
655
+ **Test.** `src/tests/security/authorization/authorization.test.ts`
656
+
657
+ **Documentation.** [docs/security/registry.md](./registry.md)