@hilbras/keystone 1.9.0 → 2.5.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/CHANGELOG.md +386 -0
- package/README.md +150 -32
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -0
- package/dist/config.js.map +1 -1
- package/dist/db/migrations/0015_chief_vargas.sql +7 -0
- package/dist/db/migrations/0016_gorgeous_charles_xavier.sql +5 -0
- package/dist/db/migrations/0017_flashy_nekra.sql +1 -0
- package/dist/db/migrations/meta/0015_snapshot.json +4340 -0
- package/dist/db/migrations/meta/0016_snapshot.json +4363 -0
- package/dist/db/migrations/meta/0017_snapshot.json +4370 -0
- package/dist/db/migrations/meta/_journal.json +21 -0
- package/dist/db/schema.d.ts +138 -1
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +39 -1
- package/dist/db/schema.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/headerSanitization.d.ts +15 -0
- package/dist/plugins/headerSanitization.d.ts.map +1 -0
- package/dist/plugins/headerSanitization.js +39 -0
- package/dist/plugins/headerSanitization.js.map +1 -0
- package/dist/plugins/mtls.d.ts +21 -2
- package/dist/plugins/mtls.d.ts.map +1 -1
- package/dist/plugins/mtls.js +94 -47
- package/dist/plugins/mtls.js.map +1 -1
- package/dist/plugins/rateLimit.d.ts +5 -4
- package/dist/plugins/rateLimit.d.ts.map +1 -1
- package/dist/plugins/rateLimit.js +11 -8
- package/dist/plugins/rateLimit.js.map +1 -1
- package/dist/repositories/application.d.ts +1 -1
- package/dist/repositories/application.d.ts.map +1 -1
- package/dist/repositories/application.js +8 -2
- package/dist/repositories/application.js.map +1 -1
- package/dist/repositories/session.d.ts +1 -0
- package/dist/repositories/session.d.ts.map +1 -1
- package/dist/repositories/types.d.ts +5 -1
- package/dist/repositories/types.d.ts.map +1 -1
- package/dist/routes/admin/organizations.d.ts.map +1 -1
- package/dist/routes/admin/organizations.js +31 -2
- package/dist/routes/admin/organizations.js.map +1 -1
- package/dist/routes/oauth.d.ts.map +1 -1
- package/dist/routes/oauth.js +38 -2
- package/dist/routes/oauth.js.map +1 -1
- package/dist/routes/oauth2.d.ts.map +1 -1
- package/dist/routes/oauth2.js +84 -11
- package/dist/routes/oauth2.js.map +1 -1
- package/dist/routes/saml.d.ts +24 -0
- package/dist/routes/saml.d.ts.map +1 -1
- package/dist/routes/saml.js +32 -3
- package/dist/routes/saml.js.map +1 -1
- package/dist/routes/serviceAccounts.d.ts.map +1 -1
- package/dist/routes/serviceAccounts.js +61 -1
- package/dist/routes/serviceAccounts.js.map +1 -1
- package/dist/routes/totp.d.ts.map +1 -1
- package/dist/routes/totp.js +4 -5
- package/dist/routes/totp.js.map +1 -1
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js.map +1 -1
- package/dist/sdk/types.d.ts +4 -1
- package/dist/sdk/types.d.ts.map +1 -1
- package/dist/services/application/organization.d.ts +5 -1
- package/dist/services/application/organization.d.ts.map +1 -1
- package/dist/services/application/organization.js.map +1 -1
- package/dist/services/connectors/google.d.ts +18 -1
- package/dist/services/connectors/google.d.ts.map +1 -1
- package/dist/services/connectors/google.js +25 -3
- package/dist/services/connectors/google.js.map +1 -1
- package/dist/services/connectors/oidc.d.ts +14 -2
- package/dist/services/connectors/oidc.d.ts.map +1 -1
- package/dist/services/connectors/oidc.js +29 -3
- package/dist/services/connectors/oidc.js.map +1 -1
- package/dist/services/connectors/types.d.ts +13 -1
- package/dist/services/connectors/types.d.ts.map +1 -1
- package/dist/services/domain/authentication.d.ts.map +1 -1
- package/dist/services/domain/authentication.js +36 -19
- package/dist/services/domain/authentication.js.map +1 -1
- package/dist/services/domain/organization.d.ts +5 -1
- package/dist/services/domain/organization.d.ts.map +1 -1
- package/dist/services/domain/organization.js.map +1 -1
- package/dist/services/events/types.d.ts +1 -1
- package/dist/services/events/types.d.ts.map +1 -1
- package/dist/services/events/validate.d.ts.map +1 -1
- package/dist/services/events/validate.js +6 -0
- package/dist/services/events/validate.js.map +1 -1
- package/dist/services/magicLinks.d.ts.map +1 -1
- package/dist/services/magicLinks.js +13 -9
- package/dist/services/magicLinks.js.map +1 -1
- package/dist/services/oauth2.d.ts +72 -2
- package/dist/services/oauth2.d.ts.map +1 -1
- package/dist/services/oauth2.js +106 -6
- package/dist/services/oauth2.js.map +1 -1
- package/dist/services/redirectUri.d.ts +56 -0
- package/dist/services/redirectUri.d.ts.map +1 -0
- package/dist/services/redirectUri.js +129 -0
- package/dist/services/redirectUri.js.map +1 -0
- package/dist/services/serviceAccounts.d.ts +20 -0
- package/dist/services/serviceAccounts.d.ts.map +1 -1
- package/dist/services/serviceAccounts.js +47 -1
- package/dist/services/serviceAccounts.js.map +1 -1
- package/dist/services/sessionRevocation.d.ts +76 -0
- package/dist/services/sessionRevocation.d.ts.map +1 -0
- package/dist/services/sessionRevocation.js +86 -0
- package/dist/services/sessionRevocation.js.map +1 -0
- package/dist/services/singleUse.d.ts +68 -0
- package/dist/services/singleUse.d.ts.map +1 -0
- package/dist/services/singleUse.js +54 -0
- package/dist/services/singleUse.js.map +1 -0
- package/dist/services/smsOtp.d.ts.map +1 -1
- package/dist/services/smsOtp.js +12 -8
- package/dist/services/smsOtp.js.map +1 -1
- package/dist/services/tokens.d.ts +2 -0
- package/dist/services/tokens.d.ts.map +1 -1
- package/dist/services/tokens.js +5 -0
- package/dist/services/tokens.js.map +1 -1
- package/dist/services/trustedProxies.d.ts +90 -0
- package/dist/services/trustedProxies.d.ts.map +1 -0
- package/dist/services/trustedProxies.js +283 -0
- package/dist/services/trustedProxies.js.map +1 -0
- package/dist/setup-server.js +2 -1
- package/dist/setup-server.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -5
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,392 @@ All notable changes to Hilbras Keystone are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [2.5.0] - 2026-09-27
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
|
|
12
|
+
- **The OIDC nonce never reached the Google connector.**
|
|
13
|
+
`GoogleConnector.exchangeCode` overrode the base method and called
|
|
14
|
+
`super.exchangeCode(code, redirectUri)` without forwarding its options, so the
|
|
15
|
+
nonce added in 2.4.0 was discarded. Every other OIDC provider validated the
|
|
16
|
+
nonce; Google — the default, and therefore the most likely to be deployed — did
|
|
17
|
+
not. An ID token minted for a different user or session would have been
|
|
18
|
+
accepted on that path.
|
|
19
|
+
- **The unsigned SAML `Issuer` was never validated.** The response-level
|
|
20
|
+
`<saml:Issuer>` sits outside both signed regions, so rewriting it does not
|
|
21
|
+
invalidate the signature, and neither samlify nor Keystone compared it to the
|
|
22
|
+
registered IdP. SAML 2.0 §2.5.1.5 requires a relying party to verify an
|
|
23
|
+
unsigned issuer against trusted metadata. An assertion could claim to have been
|
|
24
|
+
issued by a different identity provider. The assertion's own issuer is inside
|
|
25
|
+
the signed region and was always covered; this closes the element the signature
|
|
26
|
+
cannot.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- `verifyRelayState` returned by throwing on a missing or non-string signature,
|
|
31
|
+
turning a malformed RelayState — a bad request an attacker fully controls —
|
|
32
|
+
into a 500 rather than a 400. It now returns false for anything malformed.
|
|
33
|
+
- A missing `userinfoEndpoint` was passed to the fetcher behind a non-null
|
|
34
|
+
assertion, producing `userinfoEndpoint must be a valid URL` for a URL that was
|
|
35
|
+
never configured. It is now reported as unconfigured so enrichment is skipped.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- 24 adversarial SAML tests covering tampered signatures, untrusted signing keys,
|
|
40
|
+
rotated-out certificates, unsigned assertions, XML signature wrapping, issuer
|
|
41
|
+
and audience substitution, destination and recipient prefix / superstring / case
|
|
42
|
+
variants, expired assertions, `NotBefore` violations, `InResponseTo` mismatch,
|
|
43
|
+
transaction replay, and five RelayState tampering scenarios.
|
|
44
|
+
- 10 tests for OIDC userinfo endpoint resolution, Google nonce forwarding, and
|
|
45
|
+
organization-scoped membership.
|
|
46
|
+
- `docs/security/enterprise-sso.md` — SAML and OIDC setup, every check applied to
|
|
47
|
+
an assertion or ID token, certificate rotation, endpoint SSRF policy,
|
|
48
|
+
organization scoping, and recommendations.
|
|
49
|
+
|
|
50
|
+
### Already sound, verified rather than assumed
|
|
51
|
+
|
|
52
|
+
Membership is keyed on `(orgId, userId)` throughout, with a unique constraint on
|
|
53
|
+
that pair and provisioning via `ON CONFLICT DO NOTHING` — so two simultaneous
|
|
54
|
+
logins cannot create duplicate memberships. SAML connections are resolved by
|
|
55
|
+
`(connectionId, orgId)`, the transaction is consumed atomically for replay
|
|
56
|
+
protection, RelayState is HMAC-signed and bound to a browser nonce compared in
|
|
57
|
+
constant time, and assertions and messages are both required to be signed. A test
|
|
58
|
+
now pins the membership behaviour rather than leaving it to inspection.
|
|
59
|
+
|
|
60
|
+
## [2.4.0] - 2026-09-27
|
|
61
|
+
|
|
62
|
+
### Security
|
|
63
|
+
|
|
64
|
+
- **The `authorization_code` grant did not authenticate the client.** It looked
|
|
65
|
+
the application up by `client_id` and went straight to redeeming the code,
|
|
66
|
+
never calling `verifyClientSecret`. RFC 6749 §3.2.1 requires a confidential
|
|
67
|
+
client to authenticate at the token endpoint. The code and its PKCE verifier
|
|
68
|
+
were the only factors, so an intercepted code was redeemable by whoever
|
|
69
|
+
intercepted it. Confidential clients must now present `client_secret`; public
|
|
70
|
+
clients are exempt because they have none, and PKCE is what authenticates them.
|
|
71
|
+
- **Redirect URIs accepted script-bearing schemes.** Registration validated with
|
|
72
|
+
`z.string().url()`, which accepts anything the URL parser accepts — verified to
|
|
73
|
+
include `javascript:alert(1)` and
|
|
74
|
+
`data:text/html,<script>alert(1)</script>`. A redirect URI becomes a `Location`
|
|
75
|
+
header that the identity provider itself emits, so an organization admin could
|
|
76
|
+
register one and hand any user who authorized their application a redirect
|
|
77
|
+
toward script execution on the auth domain. Browser policy against top-level
|
|
78
|
+
`javascript:` navigation limits the practical impact, but on an identity
|
|
79
|
+
provider this is not an acceptable input. Registration now also rejects
|
|
80
|
+
wildcards, fragments, embedded credentials, and plaintext HTTP to non-loopback
|
|
81
|
+
hosts. A test records that `z.string().url()` accepted each of these.
|
|
82
|
+
- **OIDC federation sent no nonce and verified none.** `state` proved the callback
|
|
83
|
+
belonged to a login this browser started, but nothing bound the returned ID
|
|
84
|
+
token to that login. Any ID token the provider considered valid was accepted,
|
|
85
|
+
including one minted for a different user or session. A nonce is now generated
|
|
86
|
+
per authorization request, kept in an httpOnly cookie, sent to the provider, and
|
|
87
|
+
required to match.
|
|
88
|
+
- **ID token verification inferred rather than required.** Algorithms are now
|
|
89
|
+
pinned to RS256/ES256/PS256 instead of being derived from the key material, and
|
|
90
|
+
`exp`, `iat`, `iss`, `aud`, `sub` are required rather than validated only when
|
|
91
|
+
present — a token with no expiry was previously accepted indefinitely.
|
|
92
|
+
|
|
93
|
+
### Changed
|
|
94
|
+
|
|
95
|
+
- **Effective scopes are intersected, not trusted.** The client's `scope`
|
|
96
|
+
parameter was stored verbatim, with consent as the only filter. The effective
|
|
97
|
+
set is now registered ∩ requested ∩ consented, and a scope outside the
|
|
98
|
+
registration is refused with `invalid_scope` rather than silently dropped, so a
|
|
99
|
+
client asking for authority it was never granted is visible instead of quietly
|
|
100
|
+
downgraded. An empty `allowed_scopes` preserves existing behaviour.
|
|
101
|
+
- **Public clients.** A `client_type` column distinguishes `confidential` from
|
|
102
|
+
`public`; a public client is issued no secret rather than a secret it is
|
|
103
|
+
expected to ignore, and a check constraint keeps the two halves consistent.
|
|
104
|
+
`client_secret_hash` is now nullable. PKCE is mandatory for a secretless client
|
|
105
|
+
at both `/authorize` and `/token`; the `verifyPKCE` branch that returned true
|
|
106
|
+
when no challenge was registered is gone.
|
|
107
|
+
- **Redirect URIs are compared with one shared helper** at registration and at
|
|
108
|
+
use, so the two cannot drift. Exact string comparison throughout — no prefix
|
|
109
|
+
matching, no normalization, no case folding. The token endpoint's dead
|
|
110
|
+
`redirect_uri IS NULL` tolerance was removed: `redirect_uri` is required at
|
|
111
|
+
`/authorize`, so the branch was unreachable, and it would have accepted any
|
|
112
|
+
redirect URI had the field ever become optional.
|
|
113
|
+
- **Refresh tokens carry the granted scope set** in a new `scopes` column, so the
|
|
114
|
+
authorization context survives rotation instead of being dropped at the first
|
|
115
|
+
refresh. A refresh may narrow the grant but never widen it.
|
|
116
|
+
- **PKCE comparison is constant-time**, so a verifier cannot be recovered byte by
|
|
117
|
+
byte.
|
|
118
|
+
|
|
119
|
+
### Already sound, verified rather than assumed
|
|
120
|
+
|
|
121
|
+
Authorization code consumption was already atomic — a conditional `UPDATE` with
|
|
122
|
+
`used_at IS NULL` and a required returned row — and is now covered by a
|
|
123
|
+
concurrency test (20 parallel redemptions, exactly one winner). PKCE was already
|
|
124
|
+
required at `/authorize` by the request schema, so the dead branch in
|
|
125
|
+
`verifyPKCE` was a latent weakness rather than a live bypass. The refresh grant
|
|
126
|
+
already validated client binding, MFA context, and organization membership.
|
|
127
|
+
|
|
128
|
+
### Added
|
|
129
|
+
|
|
130
|
+
- 42 tests: redirect URI registration and exact matching, PKCE verification,
|
|
131
|
+
scope intersection, client authentication at the token endpoint, public client
|
|
132
|
+
invariants, atomic code consumption, ID token verification against a locally
|
|
133
|
+
signed key (missing / wrong / replayed / expired nonce, no-expiry, wrong issuer,
|
|
134
|
+
wrong audience, foreign signing key), and scope preservation across rotation.
|
|
135
|
+
|
|
136
|
+
## [2.3.0] - 2026-09-27
|
|
137
|
+
|
|
138
|
+
### Security
|
|
139
|
+
|
|
140
|
+
**Completing a password reset did not remove existing access.** It changed the
|
|
141
|
+
password and left every session, every refresh token, and every other
|
|
142
|
+
outstanding reset token working. A password reset is the standard response to a
|
|
143
|
+
suspected compromise, so the previous behaviour defeated its own purpose: an
|
|
144
|
+
attacker who triggered the reset kept their session and kept their access, while
|
|
145
|
+
the victim believed they had locked the intruder out.
|
|
146
|
+
|
|
147
|
+
- A successful reset now invalidates all sessions, all refresh tokens, and all
|
|
148
|
+
outstanding recovery credentials for the account.
|
|
149
|
+
- Reset tokens issued alongside the one used are now spent, so a reset email
|
|
150
|
+
captured earlier cannot be completed after the user has already recovered.
|
|
151
|
+
- Revocation is centralized in `src/services/sessionRevocation.ts`
|
|
152
|
+
(`revokeUserSessions`, `revokeRefreshTokens`,
|
|
153
|
+
`revokeAuthenticationSessions`, `revokeRecoveryCredentials`). It was
|
|
154
|
+
previously open-coded at each call site, which is how the most important site
|
|
155
|
+
came to omit it. The MFA-enablement path now routes through the same function,
|
|
156
|
+
so the rule cannot drift between the two.
|
|
157
|
+
- Revocation is scoped to one user, is idempotent, and honours an exclusion for
|
|
158
|
+
a change the user makes to their own account.
|
|
159
|
+
|
|
160
|
+
API keys are deliberately **not** revoked by a password reset. They are
|
|
161
|
+
separately issued, long-lived credentials belonging to integrations rather than
|
|
162
|
+
to the person, and killing them silently breaks deployments. The residual gap is
|
|
163
|
+
real — a key minted by an attacker who already held the password survives — and
|
|
164
|
+
key expiry and rotation is the right answer rather than coupling key lifetime to
|
|
165
|
+
a human's password.
|
|
166
|
+
|
|
167
|
+
### Already sound, verified rather than assumed
|
|
168
|
+
|
|
169
|
+
- Recovery credentials are 384-bit `crypto.randomBytes`, stored only as a SHA-256
|
|
170
|
+
digest, single-use (since 2.2.0), valid for one hour, rate-limited to 5 per 15
|
|
171
|
+
minutes, and audited.
|
|
172
|
+
- `POST /auth/forgot-password` returns `{ success: true }` on both the found and
|
|
173
|
+
not-found paths, so the response does not disclose whether an account exists.
|
|
174
|
+
(A residual timing difference remains, since the found path sends mail.)
|
|
175
|
+
|
|
176
|
+
### Added
|
|
177
|
+
|
|
178
|
+
- 9 tests, including that an attacker's session and refresh token do not survive
|
|
179
|
+
a reset, that an intercepted earlier reset token is dead, that a bystander's
|
|
180
|
+
credentials are untouched, and that the recovered user can still log in.
|
|
181
|
+
|
|
182
|
+
## [2.2.0] - 2026-09-26
|
|
183
|
+
|
|
184
|
+
### Security
|
|
185
|
+
|
|
186
|
+
Three single-use credentials were validated with a conditional `SELECT` and then
|
|
187
|
+
marked used with an **unconditional** `UPDATE`:
|
|
188
|
+
|
|
189
|
+
```text
|
|
190
|
+
SELECT ... WHERE used_at IS NULL <- conditional
|
|
191
|
+
if (!row) return
|
|
192
|
+
UPDATE ... SET used_at = now() <- UNCONDITIONAL: the race
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Between those two statements, any number of concurrent requests pass the same
|
|
196
|
+
check. Every one of them then succeeds.
|
|
197
|
+
|
|
198
|
+
- **Magic links** could be redeemed by any number of parallel requests, each
|
|
199
|
+
producing a full login. A link that was meant to be usable once was usable
|
|
200
|
+
indefinitely under concurrency.
|
|
201
|
+
- **Password reset tokens** could be spent by parallel requests, each writing a
|
|
202
|
+
different password, last writer winning. This was the most consequential of
|
|
203
|
+
the three: whoever won the race held the account, and an attacker racing the
|
|
204
|
+
legitimate user could take it over.
|
|
205
|
+
- **SMS OTP codes** could be verified more than once concurrently, so a
|
|
206
|
+
six-digit code was not single-use.
|
|
207
|
+
|
|
208
|
+
The fix is to make the write the gate rather than a follow-up:
|
|
209
|
+
|
|
210
|
+
```text
|
|
211
|
+
UPDATE ... SET used_at = now()
|
|
212
|
+
WHERE token_hash = ? AND expires_at > now() AND used_at IS NULL
|
|
213
|
+
RETURNING ...
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
PostgreSQL evaluates that predicate while holding a row lock, so exactly one
|
|
217
|
+
transaction updates the row and observes a returned row. The claim and the
|
|
218
|
+
validation become one statement with no window between them.
|
|
219
|
+
|
|
220
|
+
The four other single-use credentials in scope were already atomic and were
|
|
221
|
+
verified rather than assumed: refresh token rotation, MFA challenges, OAuth2
|
|
222
|
+
authorization codes, and TOTP backup codes all perform a conditional update and
|
|
223
|
+
require a returned row.
|
|
224
|
+
|
|
225
|
+
### Added
|
|
226
|
+
|
|
227
|
+
- `src/services/singleUse.ts` — one atomic claim and refusal-classification
|
|
228
|
+
primitive, used by all three credentials. Consumption now lives in one place,
|
|
229
|
+
so a credential cannot drift back into a hand-rolled read-then-write.
|
|
230
|
+
- Replay detection. A credential presented after it was already spent now emits
|
|
231
|
+
`magic_link_replayed`, `sms_otp_replayed`, or
|
|
232
|
+
`password_reset_token_replayed`, all of which reach the audit log through the
|
|
233
|
+
event bus. Previously a replay was indistinguishable from a typo, so a leaked
|
|
234
|
+
token returning was invisible to an operator. An **expired** credential is
|
|
235
|
+
deliberately not reported as a replay, because that is not a leak.
|
|
236
|
+
- 25 tests, including the plan's 10 / 50 / 100 concurrent-request levels against
|
|
237
|
+
every affected credential, and the same levels through the service entry points
|
|
238
|
+
a route actually calls.
|
|
239
|
+
|
|
240
|
+
### Changed
|
|
241
|
+
|
|
242
|
+
- `resetPasswordWithToken` now spends the token before doing any work, and
|
|
243
|
+
reports an expired link distinctly from an invalid one. Spending other live
|
|
244
|
+
reset tokens for the same user after a successful reset, since any were issued
|
|
245
|
+
alongside the one just used.
|
|
246
|
+
|
|
247
|
+
## [2.1.0] - 2026-09-26
|
|
248
|
+
|
|
249
|
+
### Security
|
|
250
|
+
|
|
251
|
+
Four moderate advisories in the development tree, from `drizzle-kit` pulling
|
|
252
|
+
`@esbuild-kit/esm-loader`, which pinned its own copy of `esbuild@0.18.20`
|
|
253
|
+
(`GHSA-67mh-4wv8-2f99`, fixed in 0.25.0).
|
|
254
|
+
|
|
255
|
+
The advisory allows a website to send requests to an esbuild **dev server** and
|
|
256
|
+
read the response. Keystone never calls esbuild's `serve()` API, the packages
|
|
257
|
+
are `devDependencies`, and `npm audit --omit=dev` was already clean, so this was
|
|
258
|
+
not exploitable here. It was still a real advisory in the tree that builds and
|
|
259
|
+
publishes the artifact, and `npm audit fix --force` offered only a downgrade of
|
|
260
|
+
`drizzle-kit` to 0.18.1, which is a breaking change.
|
|
261
|
+
|
|
262
|
+
Resolved with an `overrides` entry forcing `esbuild >= 0.25.0`, which collapses
|
|
263
|
+
all three copies to 0.28.2 and clears the audit for production and development
|
|
264
|
+
trees alike. Verified that `db:generate`, `db:migrate`, and `db:seed` all still
|
|
265
|
+
work against the forced version, and that no non-dev package resolves esbuild.
|
|
266
|
+
|
|
267
|
+
### Fixed
|
|
268
|
+
|
|
269
|
+
- The published container image shipped 8 HIGH-severity advisories that no
|
|
270
|
+
JavaScript scanner can detect. `npm audit` and OSV both read
|
|
271
|
+
`package-lock.json` and correctly reported zero, because the vulnerable
|
|
272
|
+
packages are not in Keystone's dependency tree: they are the ones bundled
|
|
273
|
+
inside the base image's `npm@10.9.9` (`brace-expansion@2.0.2`,
|
|
274
|
+
`ip-address@10.1.0`, `pacote@19.0.2`, `picomatch@4.0.3`, `sigstore@3.1.0`).
|
|
275
|
+
The runtime image never invokes npm — `CMD` is `node dist/index.js`, and the
|
|
276
|
+
development compose override builds the `builder` target, which keeps its own
|
|
277
|
+
npm — so it is now removed from the production stage. This clears all 8 and
|
|
278
|
+
reduces the image from 600 MB to 550 MB.
|
|
279
|
+
|
|
280
|
+
Only container scanning finds this class of problem, which is why the gate
|
|
281
|
+
exists. Keystones own `brace-expansion@5.0.12` is already above the fixed
|
|
282
|
+
version and was never affected.
|
|
283
|
+
|
|
284
|
+
### Added
|
|
285
|
+
|
|
286
|
+
- `.github/dependabot.yml` — weekly updates for npm (root and frontend), GitHub
|
|
287
|
+
Actions, and Docker. Routine patches are grouped; security updates are not, so
|
|
288
|
+
a compromised package lands alone and identifiable.
|
|
289
|
+
- `.github/workflows/supply-chain.yml` — OSV scanning (independent advisory
|
|
290
|
+
source from npm's), enforced `npm audit` over both trees, dependency review on
|
|
291
|
+
pull requests, SBOM generation, container scanning, and a license gate.
|
|
292
|
+
- `npm run verify:release` — fails on a version that disagrees between
|
|
293
|
+
`package.json` and the lockfile, a dependency in one and not the other, a
|
|
294
|
+
missing or malformed license, or a missing `repository` field. Wired into both
|
|
295
|
+
CI and the release workflow so a bad artifact cannot be published.
|
|
296
|
+
|
|
297
|
+
### Fixed
|
|
298
|
+
|
|
299
|
+
- `package.json` declared no `license` field, despite shipping an MIT `LICENSE`
|
|
300
|
+
file. The published package carried no machine-readable terms.
|
|
301
|
+
|
|
302
|
+
### Changed
|
|
303
|
+
|
|
304
|
+
- Fastify is at 5.12.5 and `fast-uri` resolves to 3.1.8, both already above the
|
|
305
|
+
5.12.2 / 3.1.7 targets. No upgrade was required.
|
|
306
|
+
- The license allowlist permits only permissive terms, with an explicit
|
|
307
|
+
exception for the pre-SPDX `MIT*` identifier that older packages emit.
|
|
308
|
+
|
|
309
|
+
## [2.0.0] - 2026-09-26
|
|
310
|
+
|
|
311
|
+
### Security
|
|
312
|
+
|
|
313
|
+
The mTLS trust boundary trusted whatever the request said about itself. Any
|
|
314
|
+
client that could reach Keystone could name a service account in a header and
|
|
315
|
+
become it, and could set its own IP address to escape every rate limit. This
|
|
316
|
+
release makes identity come only from values a client cannot forge.
|
|
317
|
+
|
|
318
|
+
- **`x-service-account-id` no longer authenticates.** It previously resolved a
|
|
319
|
+
service account on its own, with no certificate and no credential, so anyone
|
|
320
|
+
who knew or guessed an account ID became that account. It is now read only as a
|
|
321
|
+
hint alongside a valid certificate, and only when the account it names is the
|
|
322
|
+
one that certificate is bound to. A mismatch is refused, not fallen back from.
|
|
323
|
+
- **Client identity is bound to a certificate fingerprint.** A new unique
|
|
324
|
+
`service_accounts.cert_fingerprint` column pins a SHA-256 fingerprint to
|
|
325
|
+
exactly one account. Fingerprints are stored canonicalized, so the hex and
|
|
326
|
+
colon-separated spellings of one certificate cannot become two bindings, and
|
|
327
|
+
malformed values are rejected before reaching the database. Service accounts
|
|
328
|
+
are resolved by fingerprint rather than by their operator-chosen name.
|
|
329
|
+
- **Identity headers are stripped from untrusted peers.** An `onRequest` hook
|
|
330
|
+
registered before every plugin and route removes
|
|
331
|
+
`x-forwarded-client-cert`, `x-client-cert-fingerprint`,
|
|
332
|
+
`x-forwarded-client-cert-chain`, `x-service-account-id`, `x-forwarded-for`,
|
|
333
|
+
`x-real-ip`, and `forwarded` unless the peer is a configured trusted proxy.
|
|
334
|
+
Stripping rather than ignoring means a route added later cannot read a
|
|
335
|
+
spoofed identity by accident.
|
|
336
|
+
- **Rate limits can no longer be escaped.** The server was created with
|
|
337
|
+
`trustProxy: true` and the limiter read `x-forwarded-for` unconditionally, so
|
|
338
|
+
any client could present a fresh address per request and never be limited —
|
|
339
|
+
including against login, password reset, MFA verification, and SCIM. Limit keys
|
|
340
|
+
now come from the peer address unless a trusted proxy forwarded one.
|
|
341
|
+
- **Trust decisions do not use `request.ip`.** With `trustProxy` enabled that
|
|
342
|
+
value is derived from the attacker-controlled header, so the trusted-proxy
|
|
343
|
+
check uses the socket peer address, the only value a client cannot set.
|
|
344
|
+
- **Forwarded values are validated before use.** Certificate headers are
|
|
345
|
+
length-capped, and a fingerprint must be a well-formed SHA-256 digest, so
|
|
346
|
+
garbage cannot be used as a lookup key.
|
|
347
|
+
- **Inactive and revoked service accounts cannot authenticate by certificate.**
|
|
348
|
+
|
|
349
|
+
### Added
|
|
350
|
+
|
|
351
|
+
- `KEYSTONE_TRUSTED_PROXIES` — comma-separated proxy IPs, IPv4 CIDRs, or IPv6
|
|
352
|
+
prefixes permitted to set client-identity headers. Unset by default, which
|
|
353
|
+
trusts nothing. IPv4-mapped IPv6 peers are normalized before matching, and
|
|
354
|
+
unrecognized input fails closed.
|
|
355
|
+
- `PUT /v1/admin/organizations/:id/service-accounts/:accountId/certificate` —
|
|
356
|
+
bind or clear a client-certificate fingerprint, auditing
|
|
357
|
+
`service_account_certificate_bound` / `service_account_certificate_unbound`.
|
|
358
|
+
A certificate already held by another account returns `409`.
|
|
359
|
+
- `POST /v1/admin/organizations/:id/service-accounts/:accountId/revoke` —
|
|
360
|
+
permanently stop an account authenticating, auditing
|
|
361
|
+
`service_account_revoked`. Revoking an already-revoked account returns `409`
|
|
362
|
+
rather than a silent success.
|
|
363
|
+
- `docs/security/trust-boundaries.md`, `docs/security/proxy-security.md`, and
|
|
364
|
+
`docs/security/mtls.md` — the trust model, proxy requirements with working
|
|
365
|
+
nginx and ALB configuration, and the mTLS identity rules.
|
|
366
|
+
- `docs/MIGRATION-2.0.md` — migration instructions, including the failure mode
|
|
367
|
+
that presents as unrelated clients sharing a rate-limit budget.
|
|
368
|
+
|
|
369
|
+
### Fixed
|
|
370
|
+
|
|
371
|
+
- The SCIM token-hash unique indexes introduced in 1.9.0 were declared in the
|
|
372
|
+
Drizzle schema but never emitted as a migration, so they did not exist in any
|
|
373
|
+
deployed database. They are created by migration `0015`.
|
|
374
|
+
- The documented nginx configuration used `$proxy_add_x_forwarded_for`, which
|
|
375
|
+
appends to a client-supplied value and lets a client prepend a forged address.
|
|
376
|
+
Corrected to `$remote_addr`, with inbound identity headers stripped.
|
|
377
|
+
|
|
378
|
+
### Changed
|
|
379
|
+
|
|
380
|
+
- `trustProxy` is derived from `KEYSTONE_TRUSTED_PROXIES` instead of being
|
|
381
|
+
unconditionally `true`.
|
|
382
|
+
- `requireMTLS` distinguishes an untrusted peer (`401 MTLS_UNTRUSTED_PEER`) from
|
|
383
|
+
a missing certificate (`401 MTLS_CERTIFICATE_MISSING`), so a misconfiguration
|
|
384
|
+
is distinguishable from an attack.
|
|
385
|
+
|
|
386
|
+
### Breaking
|
|
387
|
+
|
|
388
|
+
- mTLS clients that authenticated with `x-service-account-id` alone must bind a
|
|
389
|
+
certificate fingerprint instead.
|
|
390
|
+
- Deployments behind a reverse proxy must set `KEYSTONE_TRUSTED_PROXIES`.
|
|
391
|
+
Without it, forwarded headers are stripped and every client shares one
|
|
392
|
+
rate-limit budget, so unrelated users can rate-limit each other.
|
|
393
|
+
|
|
8
394
|
## [1.9.0] - 2026-09-25
|
|
9
395
|
|
|
10
396
|
### Security
|