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()); }); }