prism-mcp-server 20.14.0 → 20.15.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/README.md CHANGED
@@ -724,6 +724,12 @@ Every conversation feeds a persistent store. The next session loads the right co
724
724
 
725
725
  The dashboard shows your current project state, pending TODOs, intent health, and a neural knowledge graph — all built automatically from your agent sessions.
726
726
 
727
+ It runs on loopback and is gated by a per-startup token by default — open the
728
+ tokenized URL printed in the startup log (`http://localhost:3000/?token=…`).
729
+ Requests with an untrusted `Host`/`Origin` are refused, closing the DNS-rebinding
730
+ exposure fixed in GHSA-9cvx-7x8q-3g6m. See [docs/IDE_SETUP.md](docs/IDE_SETUP.md#securing-the-dashboard)
731
+ to pin the token, disable it, or configure Basic Auth / JWKS.
732
+
727
733
  ### Export — read the record outside the agent
728
734
 
729
735
  `session_export_memory` writes your memory out as plain files you can read,
@@ -0,0 +1,43 @@
1
+ import { randomBytes } from "crypto";
2
+ import { safeCompare } from "./authUtils.js";
3
+ function isTruthy(v) {
4
+ const s = (v || "").trim().toLowerCase();
5
+ return s === "1" || s === "true" || s === "yes" || s === "on";
6
+ }
7
+ /**
8
+ * Resolve the active dashboard token, or null when token mode is off. A pinned
9
+ * token wins over a random one so operators can share a stable URL; an empty or
10
+ * whitespace-only pin is ignored (falls back to a random token).
11
+ */
12
+ export function resolveDashboardToken(cfg) {
13
+ if (cfg.authEnabled)
14
+ return null; // real auth is the gate
15
+ if (isTruthy(cfg.optOut))
16
+ return null; // explicit opt-out (Host guard still applies)
17
+ const pinned = (cfg.pinnedToken || "").trim();
18
+ if (pinned)
19
+ return pinned;
20
+ return randomBytes(32).toString("hex");
21
+ }
22
+ /** Extract the prism_dashboard_token cookie value, if present. */
23
+ export function tokenFromCookie(cookieHeader) {
24
+ // Capture the whole value (any run of non-";", non-space) so pinned tokens
25
+ // with hyphens/underscores match; the name is anchored to start-or-"; " so a
26
+ // look-alike cookie (evil_prism_dashboard_token=…) cannot match.
27
+ const m = (cookieHeader || "").match(/(?:^|;\s*)prism_dashboard_token=([^;\s]+)/);
28
+ return m ? m[1] : null;
29
+ }
30
+ /**
31
+ * True when the request presents the active token via the cookie, the
32
+ * X-Prism-Dashboard-Token header, or a ?token= query param. Every comparison is
33
+ * timing-safe and only runs against the single active token.
34
+ */
35
+ export function requestHasToken(headers, queryToken, activeToken) {
36
+ const candidates = [tokenFromCookie(headers.cookie), headers.headerToken ?? null, queryToken];
37
+ return candidates.some((c) => c !== null && safeCompare(c, activeToken));
38
+ }
39
+ /** Build the Set-Cookie value that stores the token for a browser session. */
40
+ export function buildTokenCookie(token, maxAgeMs, secure) {
41
+ return (`prism_dashboard_token=${token}; Path=/; HttpOnly; SameSite=Strict; ` +
42
+ `Max-Age=${Math.floor(maxAgeMs / 1000)}${secure ? "; Secure" : ""}`);
43
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Host / Origin allow-listing for the Mind Palace dashboard.
3
+ *
4
+ * GHSA-9cvx-7x8q-3g6m: the dashboard auto-starts on every MCP boot, binds
5
+ * loopback, and by default runs with auth disabled. Loopback binding does NOT
6
+ * stop DNS rebinding — a page the developer merely visits can rebind its own
7
+ * hostname to 127.0.0.1 and reach this server as "same-origin", carrying an
8
+ * attacker-chosen Host/Origin. The server must therefore reject any request
9
+ * whose Host (or, when present, Origin) is not a trusted local name or the
10
+ * operator-configured public origin, BEFORE any route runs and independent of
11
+ * whether auth is configured. This mirrors the standard fix for the class
12
+ * (Host-header validation, cf. CVE-2025-10193).
13
+ *
14
+ * Matching by hostname only (port-agnostic) is deliberate and safe: the attack
15
+ * requires an attacker-controlled *name*, so `localhost` / `127.0.0.1` / `[::1]`
16
+ * are trustworthy on any port — which also keeps the guard correct when the
17
+ * server falls back to PORT+1/PORT+2 on an address-in-use conflict.
18
+ */
19
+ /** Loopback host names browsers use for the local dashboard. Exact match only. */
20
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
21
+ /**
22
+ * Lowercased hostname (never the port) of a Host authority (`host[:port]`) or a
23
+ * full Origin URL. Returns null when the value cannot be parsed — a malformed or
24
+ * opaque value (e.g. the literal `null` Origin) is never trusted.
25
+ */
26
+ function hostnameOf(value, asUrl = false) {
27
+ try {
28
+ const u = asUrl ? new URL(value) : new URL(`http://${value}`);
29
+ return u.hostname.toLowerCase();
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ /** The set of hostnames that may be served: loopback + configured origin. */
36
+ function trustedHostnames(cfg) {
37
+ const names = new Set(LOOPBACK_HOSTNAMES);
38
+ if (cfg.configuredOrigin) {
39
+ const h = hostnameOf(cfg.configuredOrigin, true);
40
+ if (h)
41
+ names.add(h);
42
+ }
43
+ return names;
44
+ }
45
+ /**
46
+ * True when a request target addresses a sensitive dashboard route — the memory
47
+ * API (`/api/*`) or the MCP HTTP transport (`/sse`, `/messages`) — and must
48
+ * therefore pass the Host/Origin gate.
49
+ *
50
+ * Scoping MUST use the same normalized pathname the router resolves, or a
51
+ * dot-segment target like `/x/../api/settings` slips past a raw-string prefix
52
+ * check while the router still collapses it to `/api/settings` and serves the
53
+ * data. A fixed base keeps this independent of the attacker-supplied Host; an
54
+ * unparseable target is treated as guarded (fail closed). Public routes (the
55
+ * static shell, PWA assets, the Smithery manifest) carry no session data and
56
+ * are intentionally out of scope.
57
+ */
58
+ export function isRebindGuardedPath(requestTarget) {
59
+ let pathname;
60
+ try {
61
+ pathname = new URL(requestTarget || "/", "http://prism-dashboard.invalid").pathname;
62
+ }
63
+ catch {
64
+ return true; // unparseable target → fail closed
65
+ }
66
+ return pathname.startsWith("/api/") || pathname === "/sse" || pathname === "/messages";
67
+ }
68
+ /** True when the HTTP Host header names a trusted local or configured host. */
69
+ export function isTrustedHost(hostHeader, cfg) {
70
+ if (!hostHeader)
71
+ return false; // HTTP/1.1 requires Host; absence is not trusted.
72
+ const name = hostnameOf(hostHeader);
73
+ return name !== null && trustedHostnames(cfg).has(name);
74
+ }
75
+ /**
76
+ * True when the Origin header (if the request carries one) is trusted. A request
77
+ * with no Origin — a top-level navigation or a non-CORS GET — is not rejected on
78
+ * Origin grounds; the Host check is the load-bearing gate there.
79
+ */
80
+ export function isTrustedOrigin(originHeader, cfg) {
81
+ if (!originHeader)
82
+ return true; // absent Origin defers to the Host check
83
+ const name = hostnameOf(originHeader, true);
84
+ return name !== null && trustedHostnames(cfg).has(name);
85
+ }
86
+ /**
87
+ * Combined DNS-rebinding gate: the request must pass BOTH the Host and the
88
+ * (when present) Origin check. Call this before serving any sensitive route.
89
+ */
90
+ export function isTrustedRequest(headers, cfg) {
91
+ return isTrustedHost(headers.host, cfg) && isTrustedOrigin(headers.origin, cfg);
92
+ }
@@ -33,6 +33,8 @@ import { buildVaultDirectory } from "../utils/vaultExporter.js";
33
33
  import { redactSettings } from "../tools/commonHelpers.js";
34
34
  import { handleGraphRoutes } from "./graphRouter.js";
35
35
  import { isDashboardSettingKeyAllowed, isDashboardSettingValueAllowed } from "./settingsPolicy.js";
36
+ import { isTrustedRequest, isRebindGuardedPath } from "./hostGuard.js";
37
+ import { resolveDashboardToken, requestHasToken, buildTokenCookie, } from "./dashboardToken.js";
36
38
  import { safeCompare, generateToken, isAuthenticated, createRateLimiter, initJWKS, } from "./authUtils.js";
37
39
  const PORT = parseInt(process.env.PRISM_DASHBOARD_PORT || "3000", 10);
38
40
  /** Read HTTP request body as string (Buffer-based to avoid GC thrash on large imports) */
@@ -95,6 +97,16 @@ export async function startDashboardServer() {
95
97
  }
96
98
  const SESSION_TTL_MS = parseInt(process.env.PRISM_SESSION_TTL_MS ?? String(24 * 60 * 60 * 1000), 10);
97
99
  const activeSessions = new Map();
100
+ // ─── SECURITY: default-on dashboard token (GHSA-9cvx-7x8q-3g6m, remediation #2) ───
101
+ // Null when real auth is configured (that is the gate) or explicitly opted
102
+ // out; otherwise a random per-startup secret gates the data API as a second
103
+ // layer beneath the Host guard. Surfaced only in the startup log below.
104
+ const DASHBOARD_TOKEN = resolveDashboardToken({
105
+ authEnabled: AUTH_ENABLED,
106
+ pinnedToken: process.env.PRISM_DASHBOARD_TOKEN,
107
+ optOut: process.env.PRISM_DASHBOARD_NO_TOKEN,
108
+ });
109
+ const COOKIE_SECURE = !!process.env.PRISM_DASHBOARD_ORIGIN?.startsWith("https://") || !!process.env.PRISM_DASHBOARD_SECURE;
98
110
  // Auth config object — injectable for testing via authUtils.ts
99
111
  const authConfig = {
100
112
  authEnabled: AUTH_ENABLED,
@@ -199,8 +211,49 @@ return false;}
199
211
  res.writeHead(204);
200
212
  return res.end();
201
213
  }
214
+ // ─── SECURITY: Host / Origin allow-list (GHSA-9cvx-7x8q-3g6m) ───
215
+ // Reject DNS-rebinding requests before any sensitive route runs, independent
216
+ // of AUTH_ENABLED. A rebound browser reaches this loopback server carrying an
217
+ // attacker-controlled Host/Origin; only a trusted local name or the operator-
218
+ // configured PRISM_DASHBOARD_ORIGIN may be served the memory API / MCP
219
+ // transport. The public Smithery manifest (/.well-known/...) is intentionally
220
+ // left out of scope — it exposes no session data.
221
+ if (isRebindGuardedPath(req.url) &&
222
+ !isTrustedRequest({ host: req.headers.host, origin: req.headers.origin }, { configuredOrigin: process.env.PRISM_DASHBOARD_ORIGIN })) {
223
+ res.writeHead(403, { "Content-Type": "application/json" });
224
+ return res.end(JSON.stringify({ error: "Forbidden: untrusted Host or Origin (possible DNS rebinding)" }));
225
+ }
202
226
  // ─── v5.1: Auth login endpoint (always accessible) ───
203
227
  const reqUrl = new URL(req.url || "/", `http://${req.headers.host}`);
228
+ // ─── SECURITY: dashboard token gate (GHSA-9cvx-7x8q-3g6m, remediation #2) ───
229
+ // Second layer beneath the Host guard. Inert when DASHBOARD_TOKEN is null
230
+ // (real auth configured, or opted out). A page load carrying a valid ?token=
231
+ // is bootstrapped into a SameSite cookie so the SPA's later same-origin
232
+ // fetches authenticate transparently; the data API otherwise requires the
233
+ // token via cookie, X-Prism-Dashboard-Token header, or ?token= query.
234
+ if (DASHBOARD_TOKEN) {
235
+ const qToken = reqUrl.searchParams.get("token");
236
+ const isApiPath = reqUrl.pathname.startsWith("/api/");
237
+ if (!isApiPath && qToken && safeCompare(qToken, DASHBOARD_TOKEN)) {
238
+ reqUrl.searchParams.delete("token");
239
+ const cleanTarget = reqUrl.pathname + (reqUrl.search ? reqUrl.search : "");
240
+ res.writeHead(302, {
241
+ "Set-Cookie": buildTokenCookie(DASHBOARD_TOKEN, SESSION_TTL_MS, COOKIE_SECURE),
242
+ Location: cleanTarget,
243
+ });
244
+ return res.end();
245
+ }
246
+ if (isApiPath &&
247
+ !requestHasToken({
248
+ cookie: req.headers.cookie,
249
+ headerToken: req.headers["x-prism-dashboard-token"] || null,
250
+ }, qToken, DASHBOARD_TOKEN)) {
251
+ res.writeHead(401, { "Content-Type": "application/json" });
252
+ return res.end(JSON.stringify({
253
+ error: "Dashboard token required — open the tokenized URL printed in the Prism startup log.",
254
+ }));
255
+ }
256
+ }
204
257
  if (AUTH_ENABLED && reqUrl.pathname === "/api/auth/login" && req.method === "POST") {
205
258
  // v6.5.1: Rate limiting — prevent brute-force attacks
206
259
  const clientIP = (req.socket?.remoteAddress || "unknown").replace(/^::ffff:/, "");
@@ -260,7 +313,8 @@ return false;}
260
313
  version: SERVER_CONFIG.version,
261
314
  },
262
315
  authentication: {
263
- required: AUTH_ENABLED
316
+ // True when either configured auth or the default token gate is active.
317
+ required: AUTH_ENABLED || !!DASHBOARD_TOKEN
264
318
  },
265
319
  configSchema: {
266
320
  type: "object",
@@ -1406,7 +1460,14 @@ self.addEventListener('message', (e) => {
1406
1460
  catch {
1407
1461
  // Non-fatal — just means the user has to know the port
1408
1462
  }
1409
- console.error(`[Prism] 🧠 Mind Palace Dashboard → http://localhost:${boundPort}`);
1463
+ if (DASHBOARD_TOKEN) {
1464
+ console.error(`[Prism] 🔐 Mind Palace Dashboard → http://localhost:${boundPort}/?token=${DASHBOARD_TOKEN}`);
1465
+ console.error(`[Prism] Data API is token-gated by default (GHSA-9cvx-7x8q-3g6m). Open the URL above once; ` +
1466
+ `pin it with PRISM_DASHBOARD_TOKEN, or disable with PRISM_DASHBOARD_NO_TOKEN=1.`);
1467
+ }
1468
+ else {
1469
+ console.error(`[Prism] 🧠 Mind Palace Dashboard → http://localhost:${boundPort}`);
1470
+ }
1410
1471
  // ─── v3.1: TTL Sweep — runs at startup + every 12 hours ───────────
1411
1472
  // NOTE (v5.4): The Background Scheduler in server.ts now also handles
1412
1473
  // TTL sweeps. This dashboard sweep is kept as a legacy fallback for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.14.0",
3
+ "version": "20.15.0",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Persistent session memory for AI coding agents that never leaves your machine — including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
6
6
  "module": "index.ts",