virlow-mcp 3.11.4 → 3.11.6

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 (3) hide show
  1. package/README.md +21 -3
  2. package/dist/cli.js +71 -5
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -175,10 +175,18 @@ connection to whatever client you point at it. So:
175
175
  - The listener binds `127.0.0.1` by default. `--host` will bind elsewhere and
176
176
  warns loudly, because anyone who can reach the port and holds the token can
177
177
  read your decrypted notes.
178
- - To reach it from outside, put your own tunnel in front of it
179
- (`cloudflared`, `ssh -R`, Tailscale) rather than binding to `0.0.0.0`.
178
+ - **It speaks plain HTTP, not HTTPS.** On loopback that is fine. Off loopback it
179
+ is not: your notes and the bearer token itself travel unencrypted, readable by
180
+ anyone on the path. The token authenticates the caller; it does not protect
181
+ the bytes.
182
+ - So to reach it from outside, put a TLS-terminating tunnel in front of it
183
+ (`cloudflared`, `ssh -R`, Tailscale) rather than binding to `0.0.0.0`. That is
184
+ a confidentiality requirement, not a convenience.
180
185
  - Treat the token like a password. Delete the file to rotate it; the next start
181
186
  mints a new one.
187
+ - While it is on loopback, requests must arrive addressed to `localhost` or
188
+ `127.0.0.1`. A web page cannot reach it by pointing a hostname it controls at
189
+ your loopback address — see the DNS rebinding note below.
182
190
 
183
191
  ## Which folders AI tools can see
184
192
 
@@ -270,7 +278,17 @@ transient details.
270
278
  - **Credentials never reach the AI.** Login, master password, and any 2FA code are typed
271
279
  into the local browser form (served on `127.0.0.1` with a random one-time nonce) and
272
280
  go straight from your browser to this process and the Virlow API. No tool call ever
273
- carries a password, and the AI model never sees one.
281
+ carries a password, and the AI model never sees one. The page is served with a
282
+ content-security policy that permits no script at all, cannot be framed, and is
283
+ never cached.
284
+ - **DNS rebinding.** Loopback is not the boundary it looks like: a web page can be
285
+ made to send requests to `127.0.0.1` that your browser treats as same-origin, by
286
+ pointing a hostname the attacker controls at your loopback address. Both local
287
+ listeners — the unlock form and `--http` — refuse any request that is not
288
+ addressed to `localhost` or `127.0.0.1`, which is what a rebound request always
289
+ fails. The `--http` listener applies this only while it is on loopback: `--host`
290
+ is a deliberate choice, and a tunnel in front of it legitimately rewrites the
291
+ address.
274
292
  - **Auto-lock.** The vault locks automatically after 4 hours of inactivity, and
275
293
  immediately on explicit `lock`. Locking discards the in-memory decryption key and all
276
294
  cached memories/notes; any further tool call fails until you `unlock` again. An
package/dist/cli.js CHANGED
@@ -1238,6 +1238,39 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1238
1238
  import { randomBytes } from "node:crypto";
1239
1239
  import { spawn } from "node:child_process";
1240
1240
  import * as http from "node:http";
1241
+
1242
+ // src/local-origin.ts
1243
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
1244
+ function withoutPort(host) {
1245
+ return host.replace(/:\d+$/, "").toLowerCase();
1246
+ }
1247
+ function isLoopbackHost(hostHeader) {
1248
+ if (hostHeader === void 0 || hostHeader === "") return false;
1249
+ return LOOPBACK_HOSTS.has(withoutPort(hostHeader.trim()));
1250
+ }
1251
+ function isAllowedOrigin(originHeader) {
1252
+ if (originHeader === void 0 || originHeader === "") return true;
1253
+ try {
1254
+ return LOOPBACK_HOSTS.has(new URL(originHeader).hostname.toLowerCase());
1255
+ } catch {
1256
+ return false;
1257
+ }
1258
+ }
1259
+ function rejectReason(headers, enforce) {
1260
+ if (!enforce) return null;
1261
+ if (!isLoopbackHost(headers.host)) return "non-loopback Host";
1262
+ if (!isAllowedOrigin(headers.origin)) return "cross-site Origin";
1263
+ return null;
1264
+ }
1265
+ var SECURITY_HEADERS = Object.freeze({
1266
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
1267
+ "x-frame-options": "DENY",
1268
+ "x-content-type-options": "nosniff",
1269
+ "referrer-policy": "no-referrer",
1270
+ "cache-control": "no-store"
1271
+ });
1272
+
1273
+ // src/unlock.ts
1241
1274
  var UNLOCK_TIMEOUT_MS = 5 * 60 * 1e3;
1242
1275
  var MAX_BAD_NONCE_ATTEMPTS = 3;
1243
1276
  async function performUnlock(api, vault, creds) {
@@ -1337,12 +1370,25 @@ function renderSuccess() {
1337
1370
  </html>`;
1338
1371
  }
1339
1372
  function escapeHtml(input) {
1340
- return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1373
+ return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1341
1374
  }
1375
+ function harden(res) {
1376
+ for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
1377
+ res.setHeader(name, value);
1378
+ }
1379
+ }
1380
+ var MAX_BODY_BYTES = 64 * 1024;
1342
1381
  function readBody(req) {
1343
1382
  return new Promise((resolve, reject) => {
1344
1383
  let data = "";
1384
+ let seen = 0;
1345
1385
  req.on("data", (chunk) => {
1386
+ seen += chunk.byteLength;
1387
+ if (seen > MAX_BODY_BYTES) {
1388
+ reject(new Error("Request body too large"));
1389
+ req.destroy();
1390
+ return;
1391
+ }
1346
1392
  data += chunk.toString("utf8");
1347
1393
  });
1348
1394
  req.on("end", () => resolve(data));
@@ -1402,8 +1448,16 @@ function startUnlockServer(api, vault, opts = {}) {
1402
1448
  });
1403
1449
  });
1404
1450
  async function handleRequest(req, res) {
1451
+ if (rejectReason(req.headers, true) !== null) {
1452
+ harden(res);
1453
+ res.statusCode = 403;
1454
+ res.setHeader("content-type", "text/plain");
1455
+ res.end("Forbidden");
1456
+ return;
1457
+ }
1405
1458
  const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
1406
1459
  if (requestUrl.pathname !== "/unlock") {
1460
+ harden(res);
1407
1461
  res.statusCode = 404;
1408
1462
  res.setHeader("content-type", "text/plain");
1409
1463
  res.end("Not found");
@@ -1412,6 +1466,7 @@ function startUnlockServer(api, vault, opts = {}) {
1412
1466
  const providedNonce = requestUrl.searchParams.get("nonce");
1413
1467
  if (providedNonce !== nonce) {
1414
1468
  badNonceAttempts += 1;
1469
+ harden(res);
1415
1470
  res.statusCode = 403;
1416
1471
  res.setHeader("content-type", "text/plain");
1417
1472
  res.end("Forbidden");
@@ -1421,6 +1476,7 @@ function startUnlockServer(api, vault, opts = {}) {
1421
1476
  return;
1422
1477
  }
1423
1478
  if (req.method === "GET") {
1479
+ harden(res);
1424
1480
  res.statusCode = 200;
1425
1481
  res.setHeader("content-type", "text/html; charset=utf-8");
1426
1482
  res.end(renderForm(nonce));
@@ -1437,18 +1493,21 @@ function startUnlockServer(api, vault, opts = {}) {
1437
1493
  };
1438
1494
  try {
1439
1495
  await performUnlock(api, vault, creds);
1496
+ harden(res);
1440
1497
  res.statusCode = 200;
1441
1498
  res.setHeader("content-type", "text/html; charset=utf-8");
1442
1499
  res.end(renderSuccess());
1443
1500
  finishSuccess();
1444
1501
  } catch (err) {
1445
1502
  const message = err instanceof Error ? err.message : "Unlock failed";
1503
+ harden(res);
1446
1504
  res.statusCode = 200;
1447
1505
  res.setHeader("content-type", "text/html; charset=utf-8");
1448
1506
  res.end(renderForm(nonce, message));
1449
1507
  }
1450
1508
  return;
1451
1509
  }
1510
+ harden(res);
1452
1511
  res.statusCode = 404;
1453
1512
  res.setHeader("content-type", "text/plain");
1454
1513
  res.end("Not found");
@@ -1476,7 +1535,7 @@ function startUnlockServer(api, vault, opts = {}) {
1476
1535
  }
1477
1536
 
1478
1537
  // src/version.ts
1479
- var VERSION = true ? "3.11.4" : "dev";
1538
+ var VERSION = true ? "3.11.6" : "dev";
1480
1539
 
1481
1540
  // src/server.ts
1482
1541
  function textResult(text) {
@@ -1931,20 +1990,27 @@ async function handleWithFreshTransport(server, req, res) {
1931
1990
  async function startHttpServer(opts) {
1932
1991
  const host = opts.host ?? "127.0.0.1";
1933
1992
  const mcpPath = opts.path ?? "/mcp";
1993
+ const enforceLocal = host === "127.0.0.1" || host === "::1" || host === "localhost";
1934
1994
  const httpServer = createServer2((req, res) => {
1935
1995
  const url = new URL(req.url ?? "/", `http://${host}`);
1996
+ if (rejectReason(req.headers, enforceLocal) !== null) {
1997
+ res.writeHead(403, { ...SECURITY_HEADERS, "content-type": "application/json" });
1998
+ res.end(JSON.stringify({ error: "forbidden" }));
1999
+ return;
2000
+ }
1936
2001
  if (url.pathname === "/health") {
1937
- res.writeHead(200, { "content-type": "application/json" });
2002
+ res.writeHead(200, { ...SECURITY_HEADERS, "content-type": "application/json" });
1938
2003
  res.end(JSON.stringify({ ok: true }));
1939
2004
  return;
1940
2005
  }
1941
2006
  if (url.pathname !== mcpPath) {
1942
- res.writeHead(404).end();
2007
+ res.writeHead(404, SECURITY_HEADERS).end();
1943
2008
  return;
1944
2009
  }
1945
2010
  const provided = bearerFrom(req.headers.authorization);
1946
2011
  if (provided === null || !tokenMatches(provided, opts.token)) {
1947
2012
  res.writeHead(401, {
2013
+ ...SECURITY_HEADERS,
1948
2014
  "content-type": "application/json",
1949
2015
  "www-authenticate": "Bearer"
1950
2016
  });
@@ -2010,7 +2076,7 @@ async function main() {
2010
2076
  );
2011
2077
  if (host !== "127.0.0.1") {
2012
2078
  process.stderr.write(
2013
- "virlow-mcp: WARNING \u2014 bound to a non-loopback address. Anyone who can reach this port and holds the token can read your decrypted notes.\n"
2079
+ "virlow-mcp: WARNING \u2014 bound to a non-loopback address. Anyone who can reach this port and holds the token can read your decrypted notes.\nvirlow-mcp: WARNING \u2014 this listener speaks plain HTTP, not HTTPS. Off loopback, your notes and the bearer token travel unencrypted. Put a TLS-terminating tunnel in front of it (cloudflared, ssh -R, Tailscale) rather than exposing this port directly.\n"
2014
2080
  );
2015
2081
  }
2016
2082
  process.stderr.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "virlow-mcp",
3
- "version": "3.11.4",
3
+ "version": "3.11.6",
4
4
  "description": "Local MCP server for Virlow Secure Notes: end-to-end-encrypted AI memories and notes for Cursor, Claude, Codex, and any MCP client.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,9 +34,9 @@
34
34
  "esbuild": "^0.25.0",
35
35
  "typescript": "^5.8.0",
36
36
  "vitest": "^3.0.0",
37
- "@batalabs/virlow-crypto": "3.11.4",
38
- "@batalabs/virlow-memory": "3.11.4",
39
- "@batalabs/virlow-mcp-core": "3.11.4"
37
+ "@batalabs/virlow-crypto": "3.11.6",
38
+ "@batalabs/virlow-memory": "3.11.6",
39
+ "@batalabs/virlow-mcp-core": "3.11.6"
40
40
  },
41
41
  "scripts": {
42
42
  "build": "node build.mjs",