From 47e6731ec13695c5726d7d580de7435fb22817aa Mon Sep 17 00:00:00 2001 From: "Fredrik Liljegren (Claude Code Claude Opus 5)" Date: Fri, 21 Aug 2026 10:41:45 +0200 Subject: [PATCH] fix(server): bind to loopback and reject cross-origin writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server passed no host to listen(), so it bound the wildcard interface and every /api route — the diff, the repository's files, the review comments — was readable by anything routable to the machine. It also sent Access-Control-Allow-Origin: * on every response, so any page open in the same browser could read a private diff, and could POST to /api/revert-file, /api/revert-hunk, /api/threads and /api/github/push-comments without a preflight. DIFFITY_BIND selects the interface and defaults to 127.0.0.1, with a warning when it is widened. DIFFITY_HOST keeps its existing meaning: the hostname in the printed URL only. The UI is served from the same origin and calls /api with relative paths, so it needs no CORS headers at all. Writes now additionally require Sec-Fetch-Site: same-origin (or an absent/loopback Origin), which a cross-site page cannot forge. Navigations are GET and unaffected. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018PkYQzbsnMihHesafWvXKs --- README.md | 7 ++++- packages/cli/src/server.ts | 55 ++++++++++++++++++++++++++++++++------ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 88dd760..4498905 100644 --- a/README.md +++ b/README.md @@ -242,13 +242,18 @@ diffity list --json # machine-readable output | Variable | Description | | -------------- | ------------------------------------------------------------------------- | | `DIFFITY_HOST` | Hostname used in the printed URL (default: `localhost`). | +| `DIFFITY_BIND` | Interface the server listens on (default: `127.0.0.1`). | Useful when running diffity inside a VM or container and opening it from another machine: ```bash -DIFFITY_HOST=diffity.local diffity +DIFFITY_BIND=0.0.0.0 DIFFITY_HOST=diffity.local diffity ``` +The server has no authentication: anything that can reach it can read the diff, the +repository's files and the review comments. Only widen `DIFFITY_BIND` on a network you +trust. + ## License [PolyForm Shield 1.0.0](./LICENSE) © [Kamran Ahmed](https://x.com/kamrify) diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 050251a..018a9c4 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -76,6 +76,40 @@ export function getHost(): string { return process.env.DIFFITY_HOST?.trim() || 'localhost'; } +// The server exposes the diff, the repository's files and the review comments with no +// authentication, so it must not be reachable beyond this machine unless asked for. +export function getBindHost(): string { + return process.env.DIFFITY_BIND?.trim() || '127.0.0.1'; +} + +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']); + +function isLoopbackBind(host: string): boolean { + return LOOPBACK_HOSTNAMES.has(host) || host === '::1'; +} + +// A cross-site page cannot forge these, and same-origin fetches from the UI always satisfy +// them. Navigations are unaffected: they are GET, and Sec-Fetch-Site is `none` when the URL +// is typed rather than followed. +function isSameOriginRequest(req: IncomingMessage): boolean { + const site = req.headers['sec-fetch-site']; + if (typeof site === 'string' && site !== 'same-origin') { + return false; + } + + const origin = req.headers.origin; + if (!origin) { + return true; + } + + try { + const { hostname } = new URL(origin); + return LOOPBACK_HOSTNAMES.has(hostname) || hostname === getHost(); + } catch { + return false; + } +} + interface ServerOptions { port: number; portIsExplicit?: boolean; @@ -178,12 +212,7 @@ export function startServer(options: ServerOptions): Promise { const url = new URL(req.url || '/', `http://${getHost()}:${port}`); const pathname = url.pathname; - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader( - 'Access-Control-Allow-Methods', - 'GET, POST, PATCH, DELETE, OPTIONS', - ); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.setHeader('X-Content-Type-Options', 'nosniff'); if (req.method === 'OPTIONS') { res.writeHead(204); @@ -191,6 +220,11 @@ export function startServer(options: ServerOptions): Promise { return; } + if (req.method !== 'GET' && req.method !== 'HEAD' && !isSameOriginRequest(req)) { + sendError(res, 403, 'Cross-origin request rejected'); + return; + } + if (pathname === '/api/revert-file' && req.method === 'POST') { try { const body = JSON.parse(await readBody(req)); @@ -598,7 +632,7 @@ export function startServer(options: ServerOptions): Promise { retries++; server.close(); currentPort++; - setTimeout(() => server.listen(currentPort), 200); + setTimeout(() => server.listen(currentPort, getBindHost()), 200); } else if (err.code === 'EADDRINUSE' && portIsExplicit) { reject(new Error(`Port ${port} is already in use`)); } else { @@ -610,6 +644,11 @@ export function startServer(options: ServerOptions): Promise { server.on('listening', () => { const addr = server.address(); if (addr && typeof addr !== 'string') { + if (!isLoopbackBind(getBindHost())) { + console.warn( + ` Warning: listening on ${getBindHost()}:${addr.port} — the diff, the repository files and the review comments are readable by anyone who can reach this machine.`, + ); + } if (effectiveRef) { findOrCreateSession(effectiveRef); } @@ -630,6 +669,6 @@ export function startServer(options: ServerOptions): Promise { } }); - server.listen(currentPort); + server.listen(currentPort, getBindHost()); }); }