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.
@@ -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
- The two advisories filed to date have both produced gate or checklist work:
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 (JWT) and extract user information
11
+ * Validate a session token and extract user information.
12
12
  *
13
- * @param token - access_token (JWT)
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 invalid or expired
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;AAMH,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAyBD;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAgChF"}
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
- * Decode JWT token header and payload without verification
11
- * Extracts algorithm, user_id, and other claims for diagnostics
12
- */
13
- function decodeJWTUnverified(token) {
14
- try {
15
- const parts = token.split(".");
16
- if (parts.length !== 3) {
17
- return null;
18
- }
19
- // Decode header (first part)
20
- const header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf-8"));
21
- // Decode payload (second part)
22
- const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
23
- return { header, payload };
24
- }
25
- catch {
26
- return null;
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 (JWT)
27
+ * @param token - access_token issued by the OAuth flow
33
28
  * @returns User information including user_id
34
- * @throws Error if token is invalid or expired
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
- const decoded = decodeJWTUnverified(token);
43
- if (!decoded?.payload?.sub) {
44
- throw new Error("Invalid local session token");
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,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAC5C,OAAO,EAAE,KAAK,EAAE,MAAM,kCAAkC,CAAC;AACzD,OAAO,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAOvD;;;GAGG;AACH,SAAS,mBAAmB,CAAC,KAAa;IAIxC,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,OAAO,IAAI,CAAC;QACd,CAAC;QACD,6BAA6B;QAC7B,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;QAChF,+BAA+B;QAC/B,MAAM,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;QACjF,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;;;GAMG;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,MAAM,OAAO,GAAG,mBAAmB,CAAC,KAAK,CAAC,CAAC;QAC3C,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC;YAC3B,MAAM,IAAI,KAAK,CAAC,6BAA6B,CAAC,CAAC;QACjD,CAAC;QACD,MAAM,CAAC,IAAI,CAAC,gFAAgF,CAAC,CAAC;QAC9F,OAAO;YACL,MAAM,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG;YAC3B,KAAK,EAAE,OAAO,CAAC,OAAO,CAAC,KAAK;SAC7B,CAAC;IACJ,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"}
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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neotoma",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "MCP server for structured personal data memory with unified source ingestion",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",