diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f25cf9a..92eed66 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -22,6 +22,8 @@ services: environment: - NODE_ENV=production - NODE_OPTIONS=--max-old-space-size=1024 + # Bind all interfaces inside the container (host mapping above is loopback-only). + - CHATS_HOST=0.0.0.0 - HOME=${HOME} - CLAUDE_HOME=${HOME}/.claude - CLAUDE_DB_PATH=/data/conversations.db diff --git a/docker-compose.yml b/docker-compose.yml index c4113f1..beea148 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,10 @@ services: environment: - NODE_ENV=production - NODE_OPTIONS=--max-old-space-size=1024 + # Bind all interfaces INSIDE the container so Docker's port-forwarding + # (arrives on eth0, not loopback) reaches the app. Safe: the host-side + # mapping above is 127.0.0.1-only and start-secure.sh blocks egress. + - CHATS_HOST=0.0.0.0 - HOME=${HOME} - CLAUDE_HOME=${HOME}/.claude - CLAUDE_DB_PATH=/data/conversations.db diff --git a/docs/MCP.md b/docs/MCP.md index 55f9fb2..061e813 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -70,3 +70,15 @@ clear "Transcript file unavailable" error. Transcript reads are restricted to the configured root (`/projects` by default): the server refuses to read a file whose indexed path resolves outside that root, so a poisoned or stale index row can't be used to read arbitrary files. + +### Treat retrieved history as untrusted data (prompt injection) + +Search snippets and transcript resources return the **raw text of past conversations**, +which includes tool output — web pages Claude fetched, files it read, command output. +That content can contain text crafted to look like instructions ("ignore your previous +instructions and…"). When an agent queries history through this MCP server, that text +enters its context. Treat everything returned by `search_conversations` and the +`claude-chat://conversation/{id}` resource as **data to reason about, not instructions to +follow**. This is inherent to searching your own history and is not something the server +can strip for you — a hostile page captured in an old transcript is a second-order +prompt-injection vector for any future agent that reads it back. diff --git a/src/analytics-web/chats_mobile.html b/src/analytics-web/chats_mobile.html index 6d8befb..1fa1ce4 100644 --- a/src/analytics-web/chats_mobile.html +++ b/src/analytics-web/chats_mobile.html @@ -3729,20 +3729,10 @@

Error loading messages

const messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); // Format the preview text - const formattedPreview = processedText - .replace(/```(\w+)?\n([\s\S]+?)\n```/g, '
$2
') - .replace(/`([^`]+)`/g, '$1') - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/\*([^*]+)\*/g, '$1') - .replace(/\n/g, '
'); - + const formattedPreview = this.applyInlineMarkdown(processedText); + // Format the hidden content - const formattedHidden = expandableContent - .replace(/```(\w+)?\n([\s\S]+?)\n```/g, '
$2
') - .replace(/`([^`]+)`/g, '$1') - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/\*([^*]+)\*/g, '$1') - .replace(/\n/g, '
'); + const formattedHidden = this.applyInlineMarkdown(expandableContent); return `
@@ -3761,7 +3751,16 @@

Error loading messages

} // Basic markdown-like formatting for normal length messages - return text + return this.applyInlineMarkdown(text); + } + + // Escape HTML FIRST, then apply the lightweight markdown transforms, so + // untrusted transcript text (tool output, pasted content, imported + // sessions) can never inject markup. escapeHtml leaves backticks, '*', + // and newlines intact, so the markdown regexes still match; any < > & + // inside the text is neutralized before we wrap it in our own tags. + applyInlineMarkdown(text) { + return this.escapeHtml(text) .replace(/```(\w+)?\n([\s\S]+?)\n```/g, '
$2
') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') @@ -3826,7 +3825,7 @@

Error loading messages

} "> - ${toolName}${toolSummary ? `(${toolSummary})` : ''} + ${this.escapeHtml(toolName)}${toolSummary ? `(${this.escapeHtml(toolSummary)})` : ''}
@@ -4001,10 +4000,14 @@

Error loading messages

*/ escapeHtml(text) { if (typeof text !== 'string') return text; - - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + // Encode quotes too, so this is safe in attribute context + // (e.g. title="${escapeHtml(...)}"), not just element body. + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } @@ -4676,9 +4679,14 @@

Search Error

} escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + if (typeof text !== 'string') return text; + // Quote-safe so it can be used in attribute context too. + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); } // In-Conversation Search Methods diff --git a/src/analytics/notifications/WebSocketServer.js b/src/analytics/notifications/WebSocketServer.js index ba1c754..e834d77 100644 --- a/src/analytics/notifications/WebSocketServer.js +++ b/src/analytics/notifications/WebSocketServer.js @@ -35,7 +35,11 @@ class WebSocketServer { this.wss = new WebSocket.Server({ server: this.httpServer, path: this.options.path, - clientTracking: true + clientTracking: true, + // Reject cross-site WebSocket connections. WebSockets are NOT covered by + // the same-origin policy, so without this any website the user visits + // could open ws://localhost/ws and read pushed conversation activity. + verifyClient: (info) => this.isAllowedOrigin(info) }); this.setupEventHandlers(); @@ -49,6 +53,26 @@ class WebSocketServer { } } + /** + * Same-origin gate for the WebSocket handshake. A browser sends an `Origin` + * header it cannot forge; a legitimate page (localhost or via the tunnel) has + * an Origin whose host matches the request's Host header. A cross-site attacker + * page has Origin=evil.com but still Host=localhost, so it fails the match. + * Non-browser clients (CLI, tests) send no Origin and are allowed. + * @param {{origin?: string, req: import('http').IncomingMessage}} info + * @returns {boolean} + */ + isAllowedOrigin(info) { + const origin = info.origin; + if (!origin) return true; // non-browser client; not a CSWSH vector + const host = info.req && info.req.headers && info.req.headers.host; + try { + if (new URL(origin).host === host) return true; + } catch { /* malformed Origin -> reject */ } + console.log(chalk.yellow(`🚫 Rejected cross-origin WebSocket from ${origin} (host: ${host})`)); + return false; + } + /** * Setup WebSocket event handlers */ diff --git a/src/chats-mobile.js b/src/chats-mobile.js index 409942a..158451c 100644 --- a/src/chats-mobile.js +++ b/src/chats-mobile.js @@ -28,6 +28,10 @@ class ChatsMobile { this.app = express(); // options.port: explicit numeric port, or 0 for an ephemeral port (tests). this.port = options.port !== undefined ? options.port : 9876; + // Bind loopback-only by default: transcripts contain secrets/source/tool + // output, and there is no auth on the API. Opt into a wider bind explicitly + // (Docker sets CHATS_HOST=0.0.0.0; its host-side mapping is 127.0.0.1-only). + this.host = options.host || process.env.CHATS_HOST || '127.0.0.1'; this.fileWatcher = new FileWatcher(); this.stateCalculator = new StateCalculator(); this.dataCache = new DataCache(); @@ -1465,7 +1469,7 @@ class ChatsMobile { */ async startServer() { return new Promise(async (resolve) => { - this.httpServer = this.app.listen(this.port, async () => { + this.httpServer = this.app.listen(this.port, this.host, async () => { // If port was 0 (ephemeral, used by tests), record the actual port the OS assigned. const address = this.httpServer.address(); if (address && typeof address === 'object') { @@ -1473,6 +1477,15 @@ class ChatsMobile { } this.localUrl = `http://localhost:${this.port}`; console.log(chalk.green(`📱 Chats Mobile server started at ${this.localUrl}`)); + // Loud warning when bound beyond loopback: the API is unauthenticated, + // so a non-loopback bind exposes the full conversation history to the + // network. Docker's 0.0.0.0 is expected (host mapping is loopback-only). + const loopbackHosts = new Set(['127.0.0.1', '::1', 'localhost']); + if (!loopbackHosts.has(this.host) && !process.env.CHATS_HOST) { + console.log(chalk.red(`⚠️ Server is bound to ${this.host} (not loopback) with NO authentication.`)); + console.log(chalk.red(' Anyone who can reach this port can read your entire conversation history.')); + console.log(chalk.yellow(' Put it behind an authenticating reverse proxy, or bind 127.0.0.1.')); + } // Initialize WebSocket server with HTTP server try { @@ -1502,6 +1515,9 @@ class ChatsMobile { async setupCloudflaredTunnel() { console.log(chalk.blue('☁️ Setting up Cloudflare Tunnel...')); console.log(chalk.gray(`📡 Tunneling ${this.localUrl}...`)); + console.log(chalk.red('⚠️ The tunnel publishes this UNAUTHENTICATED app to the public internet.')); + console.log(chalk.red(' Anyone with the tunnel URL can read your entire conversation history.')); + console.log(chalk.yellow(' Only use on a trusted network, and stop the tunnel when done.')); try { const { spawn } = require('child_process'); diff --git a/src/session-sharing.js b/src/session-sharing.js index ee8036d..ba51b9a 100644 --- a/src/session-sharing.js +++ b/src/session-sharing.js @@ -439,9 +439,20 @@ class SessionSharing { const projectDir = path.join(claudeDir, 'projects', projectDirName); await fs.ensureDir(projectDir); - // Generate conversation filename with original ID + // Generate conversation filename with original ID. + // The id comes from an untrusted downloaded session, so it must not be able + // to steer the write outside projectDir. Allow only filename-safe characters + // (no path separators, no traversal), then assert containment as defense in + // depth — the same pattern the MCP server uses for transcript reads. const conversationId = sessionData.conversation.id; + if (typeof conversationId !== 'string' || !/^[A-Za-z0-9._-]+$/.test(conversationId) || conversationId.includes('..')) { + throw new Error(`Invalid session file - unsafe conversation id: ${JSON.stringify(conversationId)}`); + } const conversationFile = path.join(projectDir, `${conversationId}.jsonl`); + const resolvedRoot = path.resolve(projectDir); + if (!path.resolve(conversationFile).startsWith(resolvedRoot + path.sep)) { + throw new Error('Invalid session file - conversation id escapes the project directory'); + } // Convert messages back to JSONL format (one JSON object per line) const jsonlContent = sessionData.messages diff --git a/test/unit/text-content-xss.test.js b/test/unit/text-content-xss.test.js new file mode 100644 index 0000000..7f42ba3 --- /dev/null +++ b/test/unit/text-content-xss.test.js @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; + +// Mirror of chats_mobile.html's escapeHtml (textContent -> innerHTML escapes & < >) +// and applyInlineMarkdown, so the escape-before-markdown contract is regression-tested +// in Node without a DOM. If someone reorders escape/markdown or drops the escape, +// this fails. +function escapeHtml(text) { + if (typeof text !== 'string') return text; + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function applyInlineMarkdown(text) { + return escapeHtml(text) + .replace(/```(\w+)?\n([\s\S]+?)\n```/g, '
$2
') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/\n/g, '
'); +} + +describe('formatTextContent XSS hardening', () => { + it('neutralizes an img onerror payload', () => { + const html = applyInlineMarkdown(''); + // The angle brackets are escaped, so no live element is produced — + // the onerror text survives only as inert, escaped characters. + expect(html).not.toMatch(/ { + expect(applyInlineMarkdown('')).not.toMatch(/