Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,15 @@ clear "Transcript file unavailable" error.
Transcript reads are restricted to the configured root (`<CLAUDE_HOME>/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.
52 changes: 30 additions & 22 deletions src/analytics-web/chats_mobile.html
Original file line number Diff line number Diff line change
Expand Up @@ -3729,20 +3729,10 @@ <h4>Error loading messages</h4>
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, '<pre><code class="$1">$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/\n/g, '<br>');

const formattedPreview = this.applyInlineMarkdown(processedText);

// Format the hidden content
const formattedHidden = expandableContent
.replace(/```(\w+)?\n([\s\S]+?)\n```/g, '<pre><code class="$1">$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/\n/g, '<br>');
const formattedHidden = this.applyInlineMarkdown(expandableContent);

return `
<div class="expandable-message">
Expand All @@ -3761,7 +3751,16 @@ <h4>Error loading messages</h4>
}

// 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, '<pre><code class="$1">$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
Expand Down Expand Up @@ -3826,7 +3825,7 @@ <h4>Error loading messages</h4>
}
">
<span class="tool-bullet">⏺</span>
<span class="tool-name">${toolName}</span>${toolSummary ? `<span class="tool-summary-text">(${toolSummary})</span>` : ''}
<span class="tool-name">${this.escapeHtml(toolName)}</span>${toolSummary ? `<span class="tool-summary-text">(${this.escapeHtml(toolSummary)})</span>` : ''}
</div>
<div class="tool-expand-note" onclick="this.parentNode.querySelector('.tool-summary').click();">
<span class="tool-branch">⎿</span>
Expand Down Expand Up @@ -4001,10 +4000,14 @@ <h4>Error loading messages</h4>
*/
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}


Expand Down Expand Up @@ -4676,9 +4679,14 @@ <h3>Search Error</h3>
}

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

// In-Conversation Search Methods
Expand Down
26 changes: 25 additions & 1 deletion src/analytics/notifications/WebSocketServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
*/
Expand Down
18 changes: 17 additions & 1 deletion src/chats-mobile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -1465,14 +1469,23 @@ 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') {
this.port = address.port;
}
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 {
Expand Down Expand Up @@ -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');
Expand Down
13 changes: 12 additions & 1 deletion src/session-sharing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions test/unit/text-content-xss.test.js
Original file line number Diff line number Diff line change
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

function applyInlineMarkdown(text) {
return escapeHtml(text)
.replace(/```(\w+)?\n([\s\S]+?)\n```/g, '<pre><code class="$1">$2</code></pre>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/\n/g, '<br>');
}

describe('formatTextContent XSS hardening', () => {
it('neutralizes an img onerror payload', () => {
const html = applyInlineMarkdown('<img src=x onerror="alert(document.domain)">');
// The angle brackets are escaped, so no live <img> element is produced —
// the onerror text survives only as inert, escaped characters.
expect(html).not.toMatch(/<img/i);
expect(html).toContain('&lt;img');
expect(html).toContain('&gt;');
expect(html).not.toMatch(/<[a-z]/i); // no live tag opens from the payload
});

it('neutralizes a script tag and svg onload', () => {
expect(applyInlineMarkdown('<script>steal()</script>')).not.toMatch(/<script/i);
expect(applyInlineMarkdown('<svg onload=alert(1)>')).not.toMatch(/<svg/i);
});

it('still renders the intended markdown', () => {
expect(applyInlineMarkdown('**bold**')).toContain('<strong>bold</strong>');
expect(applyInlineMarkdown('`code`')).toContain('<code>code</code>');
expect(applyInlineMarkdown('a\nb')).toContain('a<br>b');
});

it('escapes html *inside* a code span rather than emitting a tag', () => {
const html = applyInlineMarkdown('`<b>x</b>`');
expect(html).toContain('<code>&lt;b&gt;x&lt;/b&gt;</code>');
});
});