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.
- package/README.md +21 -3
- package/dist/cli.js +71 -5
- 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
|
-
-
|
|
179
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1373
|
+
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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.
|
|
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.
|
|
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.
|
|
38
|
-
"@batalabs/virlow-memory": "3.11.
|
|
39
|
-
"@batalabs/virlow-mcp-core": "3.11.
|
|
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",
|