fix(security): shell injection, symlink escape, remove /pairing endpoint, harden defaults

- Replace all execAsync shell-interpolated git calls with execFileAsync + array args in session.ts
- Add symlink resolution in file-explorer resolveScopedPath to prevent workspace escape
- Remove /pairing HTTP endpoint from server; desktop now uses `paseo daemon pair --json` via CLI
- Disable MCP HTTP endpoint by default (opt-in via config)
- Correct SECURITY.md: fix cipher name (XSalsa20-Poly1305), accurate replay resistance claims, add local daemon trust boundary docs
This commit is contained in:
Mohamed Boudra
2026-04-02 23:58:38 +07:00
parent 1d795f6c32
commit 8a585e60f2
8 changed files with 115 additions and 62 deletions
+6 -15
View File
@@ -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<DesktopPairingOffer> {
}
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.");
}
+39 -9
View File
@@ -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<Buffer> {
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)}`,
);
}
}
-22
View File
@@ -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
+1 -1
View File
@@ -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;
@@ -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 });
}
});
});
@@ -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({
+15 -8
View File
@@ -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<string, Promise<ManagedAgent>>();
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<void> {
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<void> {
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<boolean> {
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,
});