diff --git a/SECURITY.md b/SECURITY.md index 393b044a9..6ad5fdeb2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,7 +22,7 @@ The relay is designed to be untrusted. All traffic between your phone and daemon 1. The daemon generates a persistent ECDH keypair and stores it locally 2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key 3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes. -4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with AES-256-GCM. +4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box). The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake. @@ -31,14 +31,26 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot: - **Send commands** — Without your phone's private key, it cannot complete the handshake -- **Read your traffic** — All messages are encrypted with AES-256-GCM after the handshake -- **Forge messages** — GCM provides authenticated encryption; tampered messages are rejected -- **Replay old messages** — Each session derives fresh encryption keys +- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake +- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected +- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters. ### Trust model The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly. +## Local daemon trust boundary + +By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token. + +Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address. + +If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. + +For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted. + +Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation. + ## DNS rebinding protection CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding). diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index b807c08e7..1bd61bf85 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -17,7 +17,11 @@ import { sendLocalTransportMessage, closeLocalTransportSession, } from "./local-transport.js"; -import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js"; +import { + createNodeEntrypointInvocation, + resolveDaemonRunnerEntrypoint, + runCliJsonCommand, +} from "./runtime-paths.js"; const DAEMON_LOG_FILENAME = "daemon.log"; const DAEMON_PID_FILENAME = "paseo.pid"; @@ -405,20 +409,7 @@ async function getDaemonPairing(): Promise { } try { - if (!status.listen) { - throw new Error("Daemon listen target is unavailable."); - } - const baseUrl = buildDaemonHttpBaseUrl(status.listen); - if (!baseUrl) { - throw new Error(`Daemon listen target is not a TCP endpoint: ${status.listen}`); - } - - const response = await fetch(`${baseUrl}/pairing`); - if (!response.ok) { - throw new Error(`Daemon pairing request failed with ${response.status}`); - } - - const payload = (await response.json()) as unknown; + const payload = runCliJsonCommand(["daemon", "pair", "--json"]); if (!isRecord(payload)) { throw new Error("Daemon pairing response was not an object."); } diff --git a/packages/desktop/src/daemon/runtime-paths.ts b/packages/desktop/src/daemon/runtime-paths.ts index d2b2ace8c..e630bca06 100644 --- a/packages/desktop/src/daemon/runtime-paths.ts +++ b/packages/desktop/src/daemon/runtime-paths.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; -import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { spawnSync } from "node:child_process"; import { createRequire } from "node:module"; import path from "node:path"; import { app } from "electron"; @@ -219,23 +219,22 @@ export function createNodeEntrypointInvocation(input: { }); } -function spawnCliProcess(args: string[]): SpawnSyncReturns { +function createCliInvocation(args: string[]): NodeEntrypointInvocation { const cli = resolveCliEntrypoint(); - const invocation = createNodeEntrypointInvocation({ + return createNodeEntrypointInvocation({ entrypoint: cli, argvMode: "bare", args, baseEnv: process.env, }); - - return spawnSync(invocation.command, invocation.args, { - env: invocation.env, - stdio: "inherit", - }); } export function runCliPassthroughCommand(args: string[]): number { - const result = spawnCliProcess(args); + const invocation = createCliInvocation(args); + const result = spawnSync(invocation.command, invocation.args, { + env: invocation.env, + stdio: "inherit", + }); if (result.error) { throw result.error; } @@ -246,3 +245,34 @@ export function runCliPassthroughCommand(args: string[]): number { return result.signal ? 1 : 0; } + +export function runCliJsonCommand(args: string[]): unknown { + const invocation = createCliInvocation(args); + const result = spawnSync(invocation.command, invocation.args, { + env: invocation.env, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + const stderr = typeof result.stderr === "string" ? result.stderr.trim() : ""; + throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.status}`); + } + + const stdout = typeof result.stdout === "string" ? result.stdout.trim() : ""; + if (stdout.length === 0) { + throw new Error("CLI command did not produce JSON output."); + } + + try { + return JSON.parse(stdout) as unknown; + } catch (error) { + throw new Error( + `CLI command returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index e73fe74a6..095a22e07 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -106,7 +106,6 @@ import { ScheduleService } from "./schedule/service.js"; import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; -import { generateLocalPairingOffer } from "./pairing-offer.js"; import { startRelayTransport, type RelayTransportController } from "./relay-transport.js"; import { getOrCreateServerId } from "./server-id.js"; import { resolveDaemonVersion } from "./daemon-version.js"; @@ -282,27 +281,6 @@ export async function createPaseoDaemon( }); }); - app.get("/pairing", async (_req, res) => { - try { - const offer = await generateLocalPairingOffer({ - paseoHome: config.paseoHome, - relayEnabled: config.relayEnabled, - relayEndpoint: config.relayEndpoint, - relayPublicEndpoint: config.relayPublicEndpoint, - appBaseUrl: config.appBaseUrl, - logger, - }); - res.json(offer); - } catch (error) { - logger.error({ err: error }, "Failed to generate pairing offer"); - res.status(500).json({ - relayEnabled: false, - url: null, - qr: null, - }); - } - }); - app.get("/api/files/download", async (req, res) => { const token = typeof req.query.token === "string" && req.query.token.trim().length > 0 diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 68ef52781..a0a5dddf8 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -65,7 +65,7 @@ export function loadConfig( options?.cli?.allowedHosts, ]); - const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true; + const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? false; const relayEnabled = options?.cli?.relayEnabled ?? persisted.daemon?.relay?.enabled ?? true; diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index badb1a11e..e1549e193 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -96,4 +96,25 @@ describe("file explorer service", () => { await rm(root, { recursive: true, force: true }); } }); + + it("rejects symlinked files that resolve outside the workspace", async () => { + const root = await createTempDir("paseo-file-explorer-"); + const outsideRoot = await createTempDir("paseo-file-explorer-outside-"); + + try { + const externalFile = path.join(outsideRoot, "secret.txt"); + await writeFile(externalFile, "top secret\n", "utf-8"); + await symlink(externalFile, path.join(root, "secret-link.txt")); + + await expect( + readExplorerFile({ + root, + relativePath: "secret-link.txt", + }), + ).rejects.toThrow("Access outside of workspace is not allowed"); + } finally { + await rm(root, { recursive: true, force: true }); + await rm(outsideRoot, { recursive: true, force: true }); + } + }); }); diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index b4ff4b5e5..50f139d74 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -210,11 +210,25 @@ async function resolveScopedPath({ root, relativePath = "." }: ScopedPathParams) const requestedPath = path.resolve(normalizedRoot, relativePath); const relative = path.relative(normalizedRoot, requestedPath); - if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) { - return requestedPath; + if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) { + throw new Error("Access outside of workspace is not allowed"); } - throw new Error("Access outside of workspace is not allowed"); + const realRoot = await fs.realpath(normalizedRoot); + + try { + const realPath = await fs.realpath(requestedPath); + const realRelative = path.relative(realRoot, realPath); + if (realRelative !== "" && (realRelative.startsWith("..") || path.isAbsolute(realRelative))) { + throw new Error("Access outside of workspace is not allowed"); + } + return requestedPath; + } catch (error) { + if (isMissingEntryError(error)) { + return requestedPath; + } + throw error; + } } async function buildEntryPayload({ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 6339211f3..ebb36fe52 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,7 @@ import { v4 as uuidv4 } from "uuid"; import { watch, type FSWatcher } from "node:fs"; import { readFile, stat } from "fs/promises"; -import { exec } from "child_process"; +import { exec, execFile } from "node:child_process"; import { promisify } from "util"; import { join, resolve, sep } from "path"; import { homedir } from "node:os"; @@ -185,6 +185,7 @@ import { } from "./worktree-session.js"; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); const pendingAgentInitializations = new Map>(); const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; @@ -3035,6 +3036,9 @@ export class Session { } private assertSafeGitRef(ref: string, label: string): void { + if (!/^[A-Za-z0-9._/-]+$/.test(ref)) { + throw new Error(`Invalid ${label}: ${ref}`); + } assertWorktreeSafeGitRef(ref, label); } @@ -3206,7 +3210,7 @@ export class Session { private async checkoutExistingBranch(cwd: string, branch: string): Promise { this.assertSafeGitRef(branch, "branch"); try { - await execAsync(`git rev-parse --verify ${branch}`, { cwd }); + await execFileAsync("git", ["rev-parse", "--verify", branch], { cwd }); } catch (error) { throw new Error(`Branch not found: ${branch}`); } @@ -3220,7 +3224,7 @@ export class Session { } await this.ensureCleanWorkingTree(cwd); - await execAsync(`git checkout ${branch}`, { cwd }); + await execFileAsync("git", ["checkout", branch], { cwd }); } private async createBranchFromBase(params: { @@ -3230,9 +3234,10 @@ export class Session { }): Promise { const { cwd, baseBranch, newBranchName } = params; this.assertSafeGitRef(baseBranch, "base branch"); + this.assertSafeGitRef(newBranchName, "new branch"); try { - await execAsync(`git rev-parse --verify ${baseBranch}`, { cwd }); + await execFileAsync("git", ["rev-parse", "--verify", baseBranch], { cwd }); } catch (error) { throw new Error(`Base branch not found: ${baseBranch}`); } @@ -3243,14 +3248,15 @@ export class Session { } await this.ensureCleanWorkingTree(cwd); - await execAsync(`git checkout -b ${newBranchName} ${baseBranch}`, { + await execFileAsync("git", ["checkout", "-b", newBranchName, baseBranch], { cwd, }); } private async doesLocalBranchExist(cwd: string, branch: string): Promise { + this.assertSafeGitRef(branch, "branch"); try { - await execAsync(`git show-ref --verify --quiet refs/heads/${branch}`, { + await execFileAsync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { cwd, }); return true; @@ -3653,10 +3659,11 @@ export class Session { try { const resolvedCwd = expandTilde(cwd); + this.assertSafeGitRef(branchName, "branch"); // Try local branch first try { - await execAsync(`git rev-parse --verify ${branchName}`, { + await execFileAsync("git", ["rev-parse", "--verify", branchName], { cwd: resolvedCwd, env: READ_ONLY_GIT_ENV, }); @@ -3677,7 +3684,7 @@ export class Session { // Try remote branch (origin/{branchName}) try { - await execAsync(`git rev-parse --verify origin/${branchName}`, { + await execFileAsync("git", ["rev-parse", "--verify", `origin/${branchName}`], { cwd: resolvedCwd, env: READ_ONLY_GIT_ENV, });