blob: 4bbff37cd1b94243d6b28cb393dbd686c05b4dcf [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
/**
* cookie-extract.js — read the `ponymail` HttpOnly cookie out of the local
* Chrome (or Chromium-family) cookie store so the user doesn't have to paste
* it from DevTools.
*
* ⚠️ SECURITY WARNING — READ BEFORE ENABLING THIS PATH
*
* Using this auto-extract path means the PonyMail MCP server is granted, at
* runtime, the ability to:
*
* 1. Read your Chrome cookie database
* (~/Library/Application Support/Google/Chrome/<Profile>/Cookies).
* That file contains session cookies for *every* site you are logged in
* to in Chrome, not just lists.apache.org. This module only ever queries
* the single row for host=lists.apache.org / name=ponymail, but the
* operating-system permission you grant is "read the whole file".
*
* 2. Access your macOS Keychain entry "Chrome Safe Storage" via
* `security find-generic-password`. That password is what unlocks the
* AES key used to decrypt cookie values. macOS may prompt you for the
* keychain password on first use. Once granted, the MCP process can
* decrypt *any* cookie value in the Chrome DB, not just the ponymail
* one.
*
* Both capabilities are far broader than what the MCP server actually needs.
* Treat the auto-extract path as a convenience that only makes sense if the
* MCP server is itself running under additional isolation — for example, a
* per-tool sandbox or a hardened launcher such as Apache Magpie that
* mediates which files and keychain items the process can touch.
*
* Practically, that means:
* - You must explicitly allow the MCP server out of any host-level sandbox
* that would otherwise block reads under ~/Library/Application Support/.
* - You must explicitly approve the keychain access prompt (or pre-grant
* the keychain entry to the launcher) when it appears.
*
* If you do not have a wrapping sandbox / security layer you trust, prefer
* the paste-from-DevTools fallback — it limits the MCP's exposure to the
* single cookie value you intentionally copy in.
*
* Firefox and Safari were evaluated but removed: modern Firefox (Bounce
* Tracking Protection, 109+) and modern Safari (ITP) hold OAuth-derived
* session cookies in memory only and never persist them to the on-disk
* cookie store, so there is no file for us to read.
*
* No native deps — shells out to /usr/bin/sqlite3 and /usr/bin/security.
*/
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import crypto from "node:crypto";
import { execFileSync } from "node:child_process";
const SQLITE3 = "/usr/bin/sqlite3";
const SECURITY = "/usr/bin/security";
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
/**
* Try the local Chrome (and Chromium-family) cookie store(s) for the
* `ponymail` cookie at the given host. Returns the full "name=value"
* cookie string ready to set as a Cookie: header, or null.
*
* @param {string} baseUrl - e.g. "https://lists.apache.org"
* @returns {string|null}
*/
export function extractPonymailCookie(baseUrl) {
const host = new URL(baseUrl).hostname;
if (!isSafeHost(host)) {
console.error(`[auth] refusing to query browser cookies for unsafe host: ${host}`);
return null;
}
const cookieName = "ponymail";
try {
const value = extractFromChromiumFamily(host, cookieName);
if (value) return `${cookieName}=${value}`;
} catch (err) {
console.error(`[auth] browser cookie extraction failed: ${err.message}`);
}
return null;
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
// Defensive: the host comes from new URL().hostname, but we interpolate it
// into a SQL string, so reject anything outside DNS-name characters.
function isSafeHost(host) {
return typeof host === "string" && /^[a-zA-Z0-9.-]+$/.test(host) && host.length < 256;
}
// SQLite cookie DBs may be locked while the browser is running. Copy to a
// temp file before querying so we never trip locks. WAL/SHM siblings also
// copied so we don't miss recently-written rows.
function copyDbForReading(src) {
const tmp = path.join(
os.tmpdir(),
`ponymail-mcp-cookies-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.db`
);
fs.copyFileSync(src, tmp);
for (const ext of ["-wal", "-shm"]) {
const sibling = src + ext;
if (fs.existsSync(sibling)) {
try { fs.copyFileSync(sibling, tmp + ext); } catch {}
}
}
return tmp;
}
function cleanupDb(tmp) {
for (const p of [tmp, tmp + "-wal", tmp + "-shm"]) {
try { fs.unlinkSync(p); } catch {}
}
}
function runSqlite(dbPath, sql) {
return execFileSync(SQLITE3, ["-readonly", "-bail", dbPath, sql], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
// ---------------------------------------------------------------------------
// Chromium family
// ---------------------------------------------------------------------------
function chromiumBrowsers() {
const home = os.homedir();
if (process.platform === "darwin") {
return [
{ root: path.join(home, "Library/Application Support/Google/Chrome"), keychain: "Chrome" },
{ root: path.join(home, "Library/Application Support/Chromium"), keychain: "Chromium" },
{ root: path.join(home, "Library/Application Support/BraveSoftware/Brave-Browser"), keychain: "Brave" },
{ root: path.join(home, "Library/Application Support/Microsoft Edge"), keychain: "Microsoft Edge" },
{ root: path.join(home, "Library/Application Support/Vivaldi"), keychain: "Vivaldi" },
{ root: path.join(home, "Library/Application Support/Arc/User Data"), keychain: "Arc" },
{ root: path.join(home, "Library/Application Support/com.operasoftware.Opera"), keychain: "Opera" },
];
}
if (process.platform === "linux") {
return [
{ root: path.join(home, ".config/google-chrome"), keychain: null },
{ root: path.join(home, ".config/chromium"), keychain: null },
{ root: path.join(home, ".config/BraveSoftware/Brave-Browser"), keychain: null },
{ root: path.join(home, ".config/microsoft-edge"), keychain: null },
];
}
return [];
}
function chromiumProfiles(root) {
if (!fs.existsSync(root)) return [];
let entries;
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.isDirectory())
.filter((e) => e.name === "Default" || /^Profile \d+$/.test(e.name))
.map((e) => path.join(root, e.name));
}
function chromiumCookiesPath(profileDir) {
// Newer Chromium puts the cookie DB under <profile>/Network/Cookies.
// Older versions (and some forks) keep it directly at <profile>/Cookies.
const candidates = [
path.join(profileDir, "Network", "Cookies"),
path.join(profileDir, "Cookies"),
];
return candidates.find((p) => fs.existsSync(p));
}
function extractFromChromiumFamily(host, cookieName) {
for (const browser of chromiumBrowsers()) {
for (const profile of chromiumProfiles(browser.root)) {
const dbPath = chromiumCookiesPath(profile);
if (!dbPath) continue;
const value = extractFromChromiumProfile(dbPath, browser, host, cookieName);
if (value) {
console.error(`[auth] found ponymail cookie in ${browser.root} (${path.basename(profile)})`);
return value;
}
}
}
return null;
}
function extractFromChromiumProfile(dbPath, browser, host, cookieName) {
let tmp;
try {
tmp = copyDbForReading(dbPath);
} catch {
return null;
}
try {
// Cookies table columns we care about: value (plaintext, usually empty),
// encrypted_value (BLOB). Hex-encode so we can carry binary through the
// sqlite3 CLI's pipe-separated output.
const sql =
`SELECT hex(value), hex(encrypted_value) FROM cookies ` +
`WHERE (host_key = '${host}' OR host_key = '.${host}') ` +
`AND name = '${cookieName}' LIMIT 1;`;
let out;
try {
out = runSqlite(tmp, sql).trim();
} catch {
return null; // table missing, file corrupt, etc.
}
if (!out) return null;
const [hexVal, hexEnc] = out.split("|");
if (hexVal && hexVal.length > 0) {
return Buffer.from(hexVal, "hex").toString("utf8");
}
if (hexEnc && hexEnc.length > 0) {
return decryptChromium(Buffer.from(hexEnc, "hex"), browser, host);
}
return null;
} finally {
cleanupDb(tmp);
}
}
function macKeychainPassword(account) {
try {
return execFileSync(
SECURITY,
["find-generic-password", "-w", "-s", `${account} Safe Storage`, "-a", account],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }
).replace(/\n$/, "");
} catch {
return null;
}
}
function decryptChromium(encBuf, browser, host) {
if (encBuf.length < 3) return null;
const prefix = encBuf.slice(0, 3).toString("utf8");
// v20 = App-Bound Encryption. Requires a code-signed Chromium binary to
// unwrap, which we can't do from Node. Skip and let the user fall back.
if (prefix === "v20") return null;
if (prefix !== "v10" && prefix !== "v11") return null;
const ciphertext = encBuf.slice(3);
let password;
if (process.platform === "darwin") {
if (prefix !== "v10") return null;
if (!browser.keychain) return null;
password = macKeychainPassword(browser.keychain);
if (!password) return null;
} else if (process.platform === "linux") {
// Without libsecret/kwallet bindings, we can only decrypt v10 (uses
// the well-known "peanuts" passphrase). v11 needs the keyring secret.
if (prefix !== "v10") return null;
password = "peanuts";
} else {
return null;
}
const key = crypto.pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1");
const iv = Buffer.alloc(16, 0x20); // 16 ASCII spaces
let plaintext;
try {
const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv);
plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
} catch {
return null;
}
// Recent Chromium (~v24-style scheme upgrade) prefixes the plaintext with
// SHA256(host_key) as an integrity check. Strip it when present.
if (plaintext.length >= 32) {
const expected = crypto.createHash("sha256").update(host).digest();
const expectedDot = crypto.createHash("sha256").update("." + host).digest();
const head = plaintext.slice(0, 32);
if (head.equals(expected) || head.equals(expectedDot)) {
plaintext = plaintext.slice(32);
}
}
const str = plaintext.toString("utf8");
return str.length > 0 ? str : null;
}