neotoma 0.22.0 → 0.22.1
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/dist/docs/security/advisories/2026-08-26-jwt-bearer-unverified-fallback-auth-bypass.md +59 -0
- package/dist/docs/security/advisories/README.md +1 -0
- package/dist/docs/security/practices.md +5 -1
- package/dist/services/mcp_auth.d.ts +19 -3
- package/dist/services/mcp_auth.d.ts.map +1 -1
- package/dist/services/mcp_auth.js +23 -33
- package/dist/services/mcp_auth.js.map +1 -1
- package/package.json +1 -1
package/dist/docs/security/advisories/2026-08-26-jwt-bearer-unverified-fallback-auth-bypass.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# MCP session token validation trusted an unverified JWT's own claims (v0.22.1 fix)
|
|
2
|
+
|
|
3
|
+
- **Date disclosed:** 2026-08-26
|
|
4
|
+
- **GHSA:** _draft; publish after tag per release process_
|
|
5
|
+
- **CVE:** _not requested_
|
|
6
|
+
- **Severity:** High — a remote caller could authenticate as any user without a valid session.
|
|
7
|
+
- **Affected:** all versions carrying this fallback in `validateSessionToken` prior to `0.22.1`.
|
|
8
|
+
- **Fixed in:** `0.22.1`
|
|
9
|
+
- **Reporter:** internal security review.
|
|
10
|
+
- **CWEs:** [CWE-287](https://cwe.mitre.org/data/definitions/287.html) (Improper Authentication), [CWE-347](https://cwe.mitre.org/data/definitions/347.html) (Improper Verification of Cryptographic Signature).
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
`validateSessionToken` (`src/services/mcp_auth.ts`) looked up the bearer token against `mcp_oauth_connections`. When no row matched, it fell back to decoding the bearer as an **unverified** JWT via a local `decodeJWTUnverified` helper and trusted the token's own `sub` (and `email`) claims as the authenticated identity — no signature, issuer, or expiry check. The accompanying comment described this as "local-only compatibility," but the fallback carried no local-only gate, so it ran for any caller, local or remote.
|
|
15
|
+
|
|
16
|
+
## Impact
|
|
17
|
+
|
|
18
|
+
A caller who could construct a syntactically valid but unsigned JWT (`{alg:"none"}` header, arbitrary payload, no verifiable signature) and present it as a `Bearer` token that matched no live OAuth connection was authenticated as the `sub` named in that token's own payload — including a specific victim's `user_id`, which is derivable as `sha256(email)`. Net effect: full authentication bypass on the MCP session-token path, independent of any real OAuth grant.
|
|
19
|
+
|
|
20
|
+
## Root cause
|
|
21
|
+
|
|
22
|
+
The fallback branch in `validateSessionToken` assumed that any bearer reaching this point without a matching connection row was a legitimate but differently-issued local token, and decoded-and-trusted it rather than rejecting it. This is the same regression class as the Ed25519 bearer path fixed in `0.21.4` (`2026-08-07-ed25519-bearer-forged-key-auth-bypass`): an unresolved bearer must fail closed, not fall through to trusting caller-supplied claims.
|
|
23
|
+
|
|
24
|
+
## Fix
|
|
25
|
+
|
|
26
|
+
`validateSessionToken` now fails closed: a bearer that resolves to no live `mcp_oauth_connections` row throws `Invalid session token`, unconditionally. The unused `decodeJWTUnverified` helper was removed so the unverified-decode code path no longer exists to regress into.
|
|
27
|
+
|
|
28
|
+
Regression tests (`src/services/__tests__/mcp_oauth.test.ts`, `tests/services/mcp_auth.test.ts`) assert:
|
|
29
|
+
|
|
30
|
+
- A forged `alg:none` JWT naming a real victim user's `user_id` is rejected.
|
|
31
|
+
- A forged `alg:none` JWT naming an arbitrary/nonexistent `user_id` is rejected.
|
|
32
|
+
- A random non-JWT bearer is rejected.
|
|
33
|
+
- A legitimate, live OAuth connection token still authenticates correctly (no over-correction).
|
|
34
|
+
|
|
35
|
+
## Operator action
|
|
36
|
+
|
|
37
|
+
- Upgrade to `>= 0.22.1`.
|
|
38
|
+
- No data migration required.
|
|
39
|
+
- If an instance was reachable by untrusted callers prior to `0.22.1` with the MCP session-token path enabled, review any authentication logs for bearer tokens with a `{alg:"none"}` or otherwise unverifiable header shape.
|
|
40
|
+
|
|
41
|
+
## Detection
|
|
42
|
+
|
|
43
|
+
`src/services/__tests__/mcp_oauth.test.ts` and `tests/services/mcp_auth.test.ts` detect regressions of this class going forward.
|
|
44
|
+
|
|
45
|
+
## Gates that catch this regression class going forward
|
|
46
|
+
|
|
47
|
+
- **G3b auth topology matrix** (`tests/security/auth_topology_matrix.test.ts`) — asserts protected routes reject unauthenticated/unresolved callers.
|
|
48
|
+
- **Regression tests above** — directly assert the fail-closed behavior for unresolved bearers on the MCP session-token path.
|
|
49
|
+
- **Change guardrails rule MUST 5** — authorization must go through a resolved principal; caller-supplied claims are never trusted directly for access control.
|
|
50
|
+
|
|
51
|
+
## Timeline
|
|
52
|
+
|
|
53
|
+
| Date | Event |
|
|
54
|
+
|------|-------|
|
|
55
|
+
| 2026-08-26 | Vulnerability identified during internal security review |
|
|
56
|
+
| 2026-08-26 | Fix PR #2232 merged |
|
|
57
|
+
| 2026-08-26 | v0.22.1 supplement prepared |
|
|
58
|
+
| TBD | v0.22.1 tagged and released |
|
|
59
|
+
| TBD | GHSA published |
|
|
@@ -14,6 +14,7 @@ This index is the canonical cross-link target for:
|
|
|
14
14
|
|
|
15
15
|
| Date | Title | GHSA | CVE | Affected | Fixed in | Gate that catches the regression class |
|
|
16
16
|
|------|-------|------|-----|----------|----------|----------------------------------------|
|
|
17
|
+
| 2026-08-26 | [MCP session token validation trusted an unverified JWT's own claims](2026-08-26-jwt-bearer-unverified-fallback-auth-bypass.md) | _draft_ | _not requested_ | all versions prior to `0.22.1` | `0.22.1` | `src/services/__tests__/mcp_oauth.test.ts`, `tests/services/mcp_auth.test.ts`, G3 auth topology matrix |
|
|
17
18
|
| 2026-08-09 | [Stored XSS in rendered_page HTML route via author-supplied html_body](2026-08-09-rendered-page-stored-xss-csp.md) | [GHSA-qp63-9r52-4q25](https://github.com/markmhendrickson/neotoma/security/advisories/GHSA-qp63-9r52-4q25) | _not requested_ | all versions prior to `0.21.5` | `0.21.5` | `tests/security/rendered_page_csp.test.ts` |
|
|
18
19
|
| 2026-08-07 | [Ed25519 bearer auth: unresolved principal returned caller-supplied user_id](2026-08-07-ed25519-bearer-forged-key-auth-bypass.md) | [GHSA-33x4-v5cf-2hfj](https://github.com/markmhendrickson/neotoma/security/advisories/GHSA-33x4-v5cf-2hfj) | _not requested_ | all versions prior to `0.21.4` | `0.21.4` | `tests/security/ed25519_forged_key_auth_bypass.test.ts`, G3 auth topology matrix |
|
|
19
20
|
| 2026-08-07 | [sort_by / snapshot_filters ORDER BY SQL injection](2026-08-07-sort-by-order-by-sql-injection.md) | [GHSA-8f95-jfm5-jjmr](https://github.com/markmhendrickson/neotoma/security/advisories/GHSA-8f95-jfm5-jjmr) | _not requested_ | all versions prior to `0.21.4` | `0.21.4` | `tests/security/sort_by_sql_injection.test.ts`, `normalizeColumnName` fail-closed backstop |
|
|
@@ -137,10 +137,14 @@ Every advisory in `docs/security/advisories/` MUST result in one of:
|
|
|
137
137
|
1. A new gate, rule, or checklist category (documented in this file under the relevant section)
|
|
138
138
|
2. An explicit waiver with a written reason (documented in the advisory's Gate Gap section)
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
Advisories filed to date have each produced gate or regression-test work:
|
|
141
141
|
|
|
142
142
|
- `2026-05-11-inspector-auth-bypass` → G2 rules, G3a manifest, G3b matrix test, checklist categories 1–5 (all landed in v0.11.1)
|
|
143
143
|
- `2026-05-21-relationship-endpoint-tenant-isolation` → G3b extension, checklist category 6 (pending, tracked in #372)
|
|
144
|
+
- `2026-08-07-ed25519-bearer-forged-key-auth-bypass` → fail-closed fix on the Ed25519 bearer path plus `tests/security/ed25519_forged_key_auth_bypass.test.ts` (landed in v0.21.4)
|
|
145
|
+
- `2026-08-07-sort-by-order-by-sql-injection` → `normalizeColumnName` fail-closed backstop plus `tests/security/sort_by_sql_injection.test.ts` (landed in v0.21.4)
|
|
146
|
+
- `2026-08-09-rendered-page-stored-xss-csp` → CSP hardening plus `tests/security/rendered_page_csp.test.ts` (landed in v0.21.5)
|
|
147
|
+
- `2026-08-26-jwt-bearer-unverified-fallback-auth-bypass` → fail-closed fix on the MCP session-token bearer path (removed the unverified-JWT decode fallback), plus regression tests in `src/services/__tests__/mcp_oauth.test.ts` and `tests/services/mcp_auth.test.ts` (landed in v0.22.1). Same regression class as the Ed25519 bearer path above — an unresolved bearer must fail closed rather than fall through to trusting caller-supplied claims.
|
|
144
148
|
|
|
145
149
|
### Single-tenant assumption
|
|
146
150
|
|
|
@@ -8,11 +8,27 @@ export interface ValidatedUser {
|
|
|
8
8
|
email?: string;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
* Validate a session token
|
|
11
|
+
* Validate a session token and extract user information.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* A session token is valid only if it maps to a live (non-revoked, unexpired)
|
|
14
|
+
* row in `mcp_oauth_connections`. A token that matches no such row is rejected
|
|
15
|
+
* — claims decoded from the token itself are never trusted.
|
|
16
|
+
*
|
|
17
|
+
* SECURITY: a previous revision fell back to decoding the bearer as an
|
|
18
|
+
* UNVERIFIED JWT and trusting its `sub`/`email` claims when no connection row
|
|
19
|
+
* matched. That is an authentication bypass: the token is fully
|
|
20
|
+
* attacker-controlled, so `Bearer <base64url({alg:none})>.<base64url({sub:<any
|
|
21
|
+
* user_id>})>.x` authenticated the caller as any user over the public internet
|
|
22
|
+
* (there was no local-only gate despite the "local-only" comment; the claimed
|
|
23
|
+
* `user_id` is derivable as sha256(email)). The fix enforces the same
|
|
24
|
+
* fail-closed invariant the Ed25519 bearer path adopted after advisory
|
|
25
|
+
* 2026-08-07-ed25519-bearer-forged-key-auth-bypass: a bearer that does not
|
|
26
|
+
* resolve to a pre-provisioned principal must be rejected, never mapped to a
|
|
27
|
+
* caller-supplied identity.
|
|
28
|
+
*
|
|
29
|
+
* @param token - access_token issued by the OAuth flow
|
|
14
30
|
* @returns User information including user_id
|
|
15
|
-
* @throws Error if token is
|
|
31
|
+
* @throws Error if the token is unknown, revoked, or expired
|
|
16
32
|
*/
|
|
17
33
|
export declare function validateSessionToken(token: string): Promise<ValidatedUser>;
|
|
18
34
|
//# sourceMappingURL=mcp_auth.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mcp_auth.d.ts","sourceRoot":"","sources":["../../src/services/mcp_auth.ts"],"names":[],"mappings":"AAAA;;;;GAIG;
|
|
1
|
+
{"version":3,"file":"mcp_auth.d.ts","sourceRoot":"","sources":["../../src/services/mcp_auth.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CA2BhF"}
|
|
@@ -3,35 +3,30 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Validates session tokens for MCP server authentication
|
|
5
5
|
*/
|
|
6
|
-
import { logger } from "../utils/logger.js";
|
|
7
6
|
import { getDb } from "../repositories/db/connection.js";
|
|
8
7
|
import { getLocalAuthUserById } from "./local_auth.js";
|
|
9
8
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Validate a session token (JWT) and extract user information
|
|
9
|
+
* Validate a session token and extract user information.
|
|
10
|
+
*
|
|
11
|
+
* A session token is valid only if it maps to a live (non-revoked, unexpired)
|
|
12
|
+
* row in `mcp_oauth_connections`. A token that matches no such row is rejected
|
|
13
|
+
* — claims decoded from the token itself are never trusted.
|
|
14
|
+
*
|
|
15
|
+
* SECURITY: a previous revision fell back to decoding the bearer as an
|
|
16
|
+
* UNVERIFIED JWT and trusting its `sub`/`email` claims when no connection row
|
|
17
|
+
* matched. That is an authentication bypass: the token is fully
|
|
18
|
+
* attacker-controlled, so `Bearer <base64url({alg:none})>.<base64url({sub:<any
|
|
19
|
+
* user_id>})>.x` authenticated the caller as any user over the public internet
|
|
20
|
+
* (there was no local-only gate despite the "local-only" comment; the claimed
|
|
21
|
+
* `user_id` is derivable as sha256(email)). The fix enforces the same
|
|
22
|
+
* fail-closed invariant the Ed25519 bearer path adopted after advisory
|
|
23
|
+
* 2026-08-07-ed25519-bearer-forged-key-auth-bypass: a bearer that does not
|
|
24
|
+
* resolve to a pre-provisioned principal must be rejected, never mapped to a
|
|
25
|
+
* caller-supplied identity.
|
|
31
26
|
*
|
|
32
|
-
* @param token - access_token
|
|
27
|
+
* @param token - access_token issued by the OAuth flow
|
|
33
28
|
* @returns User information including user_id
|
|
34
|
-
* @throws Error if token is
|
|
29
|
+
* @throws Error if the token is unknown, revoked, or expired
|
|
35
30
|
*/
|
|
36
31
|
export async function validateSessionToken(token) {
|
|
37
32
|
const db = await getDb();
|
|
@@ -39,15 +34,10 @@ export async function validateSessionToken(token) {
|
|
|
39
34
|
.prepare("SELECT user_id, access_token_expires_at FROM mcp_oauth_connections WHERE access_token = ? AND revoked_at IS NULL")
|
|
40
35
|
.get(token));
|
|
41
36
|
if (!connection?.user_id) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
logger.warn("[MCP Auth] Falling back to unverified JWT payload for local-only compatibility");
|
|
47
|
-
return {
|
|
48
|
-
userId: decoded.payload.sub,
|
|
49
|
-
email: decoded.payload.email,
|
|
50
|
-
};
|
|
37
|
+
// Fail closed: a bearer that matches no live connection is not a valid
|
|
38
|
+
// session. Never decode and trust the token's own claims (see SECURITY
|
|
39
|
+
// note above).
|
|
40
|
+
throw new Error("Invalid session token");
|
|
51
41
|
}
|
|
52
42
|
if (connection.access_token_expires_at &&
|
|
53
43
|
new Date(connection.access_token_expires_at).getTime() < Date.now()) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mcp_auth.js","sourceRoot":"","sources":["../../src/services/mcp_auth.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"mcp_auth.js","sourceRoot":"","sources":["../../src/services/mcp_auth.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,kCAAkC,CAAC;AACzD,OAAO,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAOvD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,KAAa;IACtD,MAAM,EAAE,GAAG,MAAM,KAAK,EAAE,CAAC;IACzB,MAAM,UAAU,GAAG,CAAC,MAAM,EAAE;SACzB,OAAO,CACN,kHAAkH,CACnH;SACA,GAAG,CAAC,KAAK,CAAC,CAAuE,CAAC;IAErF,IAAI,CAAC,UAAU,EAAE,OAAO,EAAE,CAAC;QACzB,uEAAuE;QACvE,uEAAuE;QACvE,eAAe;QACf,MAAM,IAAI,KAAK,CAAC,uBAAuB,CAAC,CAAC;IAC3C,CAAC;IAED,IACE,UAAU,CAAC,uBAAuB;QAClC,IAAI,IAAI,CAAC,UAAU,CAAC,uBAAuB,CAAC,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,GAAG,EAAE,EACnE,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,6BAA6B,CAAC,CAAC;IACjD,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,oBAAoB,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;IAC5D,OAAO;QACL,MAAM,EAAE,UAAU,CAAC,OAAO;QAC1B,KAAK,EAAE,IAAI,EAAE,KAAK;KACnB,CAAC;AACJ,CAAC"}
|