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:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user