Skip to content
Merged
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
19 changes: 19 additions & 0 deletions .github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,25 @@ npx opendia --tunnel
- Copy URL for ChatGPT/online AI services
- Local functionality preserved

Because the tunnel is public, `/sse` requires a bearer token in this mode. The
server prints one at startup; pass `--token=<value>` to pin a fixed one across
restarts. Send it with every request:

```bash
curl -H "Authorization: Bearer <token>" https://<tunnel>.ngrok-free.app/sse
```

### Network Binding
Both listeners bind `127.0.0.1` by default, and `ngrok` connects from this machine,
so the tunnel works without widening anything. To expose the HTTP/SSE port on the
LAN deliberately:

```bash
npx opendia --http-host=0.0.0.0 # requires a token, same as --tunnel
```

The WebSocket extension channel always stays on loopback.

**Note**: For auto-tunneling to work, you need ngrok installed:

**macOS:**
Expand Down
32 changes: 22 additions & 10 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,28 @@ build.
The trust boundary is **your machine**. The MCP server and extension are designed
to talk only to each other, locally.

- **The local server is localhost-scoped by default.** The extension auto-connects
to `ws://localhost:5555`; SSE is on `http://localhost:5556`. Anything that lets an
arbitrary web page or a *different* extension reach that bridge and issue browser
actions is a serious finding.
- **`--tunnel` mode is opt-in and public.** `npx opendia --tunnel` publishes the
local server through an ngrok tunnel so a remote client (e.g. ChatGPT) can reach
it. **Anyone who learns that URL can drive your browser with your logged-in
sessions.** Treat the tunnel URL as a secret, only enable it when you need it, and
shut it down afterward. Weaknesses in how the tunnel is exposed or authenticated
are in scope.
- **The local server is localhost-scoped by default.** Both listeners bind
`127.0.0.1` — the extension auto-connects to `ws://localhost:5555`, and SSE is on
`http://localhost:5556`. Anything that lets an arbitrary web page or a *different*
extension reach that bridge and issue browser actions is a serious finding.
- **Loopback alone does not keep web pages out**, because a page you visit can also
reach `127.0.0.1`. Both the WebSocket handshake and the HTTP surface therefore
refuse any request carrying a page Origin: browsers send `Origin` cross-origin, so
`http(s)://…` and sandboxed `null` are rejected, while extension service workers
(`chrome-extension://`, `moz-extension://`, `safari-web-extension://`) and
non-browser MCP clients (which send no `Origin`) are allowed. `Access-Control-Allow-Origin`
is never `*`; it is reflected only for allowed origins, so a page cannot read a
response even to a request it manages to send.
- **Reaching past this machine requires a token.** `--tunnel` (ngrok) and
`--http-host=<addr>` both put `/sse` somewhere other people can reach, so both
turn on bearer-token auth: the server generates a token at startup and prints it,
or you pin one with `--token=<value>`. Callers send `Authorization: Bearer <token>`.
**Anyone who learns the tunnel URL *and* the token can drive your browser with your
logged-in sessions** — treat both as secrets, only enable the tunnel when you need
it, and shut it down afterward. Weaknesses in how the tunnel is exposed or
authenticated are in scope.
- **The WebSocket control channel is never widened.** `--http-host` moves only the
HTTP/SSE listener; the extension channel stays on loopback regardless.
- **The extension acts as you.** Because it uses your existing cookies, sessions,
and saved credentials, every action runs with your authority. Only pair OpenDia
with an AI client you trust — a malicious or prompt-injected model can ask the
Expand Down
103 changes: 88 additions & 15 deletions opendia-mcp/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const { exec } = require('child_process');

// ADD: New imports for SSE transport
const cors = require('cors');
const crypto = require('crypto');
const { createServer } = require('http');
const { spawn } = require('child_process');

Expand All @@ -21,6 +22,26 @@ const wsPortArg = args.find(arg => arg.startsWith('--ws-port='));
const httpPortArg = args.find(arg => arg.startsWith('--http-port='));
const portArg = args.find(arg => arg.startsWith('--port='));

// `POST /sse` hands whatever it receives to handleMCPRequest, which drives the
// browser through the extension. Keep that surface on loopback by default; the
// extension reaches it at localhost and `ngrok http` connects from this machine
// too, so neither needs an off-host bind. --http-host= widens it deliberately.
const httpHostArg = args.find(arg => arg.startsWith('--http-host='));
const HTTP_HOST = httpHostArg ? httpHostArg.split('=')[1] : '127.0.0.1';

function isLoopbackHost(host) {
return host === '127.0.0.1' || host === '::1' || host === 'localhost';
}

// Once the MCP surface is reachable beyond this machine — widened bind or an
// ngrok tunnel — loopback stops being the boundary and callers must present a
// token. Locally it stays off so existing setups keep working untouched.
const tokenArg = args.find(arg => arg.startsWith('--token='));
const requiresToken = enableTunnel || !isLoopbackHost(HTTP_HOST);
const AUTH_TOKEN = requiresToken
? (tokenArg ? tokenArg.split('=')[1] : crypto.randomBytes(24).toString('hex'))
: null;

// Default ports (changed from 3000/3001 to 5555/5556)
let WS_PORT = wsPortArg ? parseInt(wsPortArg.split('=')[1]) : (portArg ? parseInt(portArg.split('=')[1]) : 5555);
let HTTP_PORT = httpPortArg ? parseInt(httpPortArg.split('=')[1]) : (portArg ? parseInt(portArg.split('=')[1]) + 1 : 5556);
Expand Down Expand Up @@ -143,7 +164,55 @@ async function handlePortConflict(port, portName) {

// ADD: Express app setup
const app = express();
app.use(cors());

// Loopback is not a trust boundary for a browser: a page the user visits can
// reach 127.0.0.1 too. Same rule the WebSocket handshake uses — a page announces
// itself with an http(s) Origin (or "null" when sandboxed) and is refused, while
// extension service workers send an extension-scheme Origin and non-browser MCP
// clients send none.
const EXTENSION_ORIGIN = /^(chrome|moz|safari-web)-extension:\/\//;

function isAllowedOrigin(origin) {
return !origin || EXTENSION_ORIGIN.test(origin);
}

// Never reflect `*`, or a page could read the response to a request it is
// allowed to send. Denying the origin only omits the header; the hard refusal is
// guardOrigin below, so a simple request that skips preflight is still blocked.
app.use(cors({
origin: (origin, callback) => callback(null, isAllowedOrigin(origin)),
allowedHeaders: ['Content-Type', 'Cache-Control', 'Authorization'],
methods: ['GET', 'POST', 'OPTIONS']
}));

function guardOrigin(req, res, next) {
const origin = req.headers.origin;
if (!isAllowedOrigin(origin)) {
console.error(`🚫 Rejected ${req.method} ${req.path} from origin ${origin}`);
return res.status(403).json({ error: 'Forbidden' });
}
return next();
}

function timingSafeEqual(a, b) {
const left = Buffer.from(a);
const right = Buffer.from(b);
return left.length === right.length && crypto.timingSafeEqual(left, right);
}

function requireToken(req, res, next) {
if (!AUTH_TOKEN) return next();

const header = req.headers.authorization || '';
const provided = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!provided || !timingSafeEqual(provided, AUTH_TOKEN)) {
console.error(`🚫 Rejected ${req.method} ${req.path} — missing or bad token`);
return res.status(401).json({ error: 'Unauthorized' });
}
return next();
}

app.use(guardOrigin);
app.use(express.json());

// WebSocket server for Chrome/Firefox Extension (will be initialized after port conflict resolution)
Expand Down Expand Up @@ -1695,15 +1764,13 @@ function setupWebSocketHandlers() {

// ADD: SSE/HTTP endpoints for online AI
app.route('/sse')
.all(requireToken)
.get((req, res) => {
// SSE stream for connection
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control, Content-Type',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS'
'Connection': 'keep-alive'
});

res.write(`data: ${JSON.stringify({
Expand Down Expand Up @@ -1751,13 +1818,8 @@ app.route('/sse')
}
});

// ADD: CORS preflight handler
app.options('/*splat', (req, res) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Cache-Control');
res.sendStatus(200);
});
// Preflight is answered by the cors() middleware above, which reflects only
// allowed origins — a blanket handler here would hand `*` back to any page.

// Read from stdin
let inputBuffer = "";
Expand Down Expand Up @@ -1867,9 +1929,16 @@ async function startServer() {
console.error(`✅ Ports resolved: WebSocket=${WS_PORT}, HTTP=${HTTP_PORT}`);

// Start HTTP server
const httpServer = app.listen(HTTP_PORT, () => {
console.error(`🌐 HTTP/SSE server running on port ${HTTP_PORT}`);
console.error(`🔌 Browser Extension connected on ws://localhost:${WS_PORT}`);
const httpServer = app.listen(HTTP_PORT, HTTP_HOST, () => {
console.error(`🌐 HTTP/SSE server running on ${HTTP_HOST}:${HTTP_PORT}`);
console.error(`🔌 Browser Extension connects on ws://localhost:${WS_PORT}`);
if (!isLoopbackHost(HTTP_HOST)) {
console.error(`⚠️ Bound beyond loopback (--http-host=${HTTP_HOST}) — /sse requires the token below`);
}
if (AUTH_TOKEN) {
console.error(`🔑 /sse token: ${AUTH_TOKEN}`);
console.error(' Send it as: Authorization: Bearer <token>');
}
console.error("🎯 Features: Anti-detection bypass + intelligent automation");
});

Expand Down Expand Up @@ -1923,6 +1992,10 @@ async function startServer() {
console.error('📋 Copy this URL for online AI services:');
console.error(`🔗 ${tunnelUrl}/sse`);
console.error('');
console.error('🔑 This URL is public — the tunnel requires a token:');
console.error(` Authorization: Bearer ${AUTH_TOKEN}`);
console.error(' Reuse a fixed one across restarts with --token=<value>');
console.error('');
console.error('💡 ChatGPT: Settings → Connectors → Custom Connector');
console.error('💡 Claude Web: Add as external MCP server (if supported)');
console.error('');
Expand Down