Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions src/sync/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
stripOverrideKeys,
} from './mcp-secrets.js';
import type { ExtraPathPlan, SyncItem, SyncPlan } from './paths.js';
import { normalizePath } from './paths.js';
import { expandHome, normalizePath } from './paths.js';

type ExtraPathType = 'file' | 'dir';

Expand Down Expand Up @@ -241,7 +241,7 @@ async function applyExtraPaths(plan: SyncPlan, extra: ExtraPathPlan): Promise<vo
const repoPath = path.isAbsolute(entry.repoPath)
? entry.repoPath
: path.join(plan.repoRoot, entry.repoPath);
const localPath = entry.sourcePath;
const localPath = path.resolve(expandHome(entry.sourcePath, plan.homeDir));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The applyExtraPaths function trusts the repoPath value from the extra-manifest.json file in the repository. If this value is an absolute path or contains path traversal sequences (e.g., ../../), it can point to arbitrary files on the user's system. Since this path is used as the source for a copy operation where the destination is an allowlisted local path, an attacker who controls the repository can trick the system into copying sensitive files (like /etc/shadow or SSH keys) into the sync directory. A subsequent sync-push would then upload these sensitive files to the repository, leading to data exfiltration. Additionally, the mode property from the manifest is used in chmod without validation, allowing an attacker to change permissions of allowlisted files.

const entryType: ExtraPathType = entry.type ?? 'file';

if (!(await pathExists(repoPath))) continue;
Expand All @@ -266,13 +266,15 @@ async function writeExtraPathManifest(plan: SyncPlan, extra: ExtraPathPlan): Pro

for (const entry of extra.entries) {
const sourcePath = entry.sourcePath;
if (!(await pathExists(sourcePath))) {
const actualSourcePath = path.resolve(expandHome(sourcePath, plan.homeDir));

if (!(await pathExists(actualSourcePath))) {
continue;
}
const stat = await fs.stat(sourcePath);
const stat = await fs.stat(actualSourcePath);
if (stat.isDirectory()) {
await copyDirRecursive(sourcePath, entry.repoPath);
const items = await collectExtraPathItems(sourcePath, sourcePath);
await copyDirRecursive(actualSourcePath, entry.repoPath);
const items = await collectExtraPathItems(actualSourcePath, actualSourcePath);
entries.push({
sourcePath,
repoPath: path.relative(plan.repoRoot, entry.repoPath),
Expand All @@ -283,7 +285,7 @@ async function writeExtraPathManifest(plan: SyncPlan, extra: ExtraPathPlan): Pro
continue;
}
if (stat.isFile()) {
await copyFileWithMode(sourcePath, entry.repoPath);
await copyFileWithMode(actualSourcePath, entry.repoPath);
entries.push({
sourcePath,
repoPath: path.relative(plan.repoRoot, entry.repoPath),
Expand Down
38 changes: 38 additions & 0 deletions src/sync/paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,44 @@ describe('buildSyncPlan', () => {
expect(plan.extraConfigs.allowlist.length).toBe(1);
});

it('stores extra manifest sourcePath using portable home paths', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: true,
extraSecretPaths: ['/home/test/.ssh/id_rsa'],
extraConfigPaths: [
'/home/test/.config/opencode/custom.json',
'~/.config/opencode/other.json',
],
};

const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux');
const sources = plan.extraConfigs.entries.map((e) => e.sourcePath).sort();

expect(sources).toEqual(['~/.config/opencode/custom.json', '~/.config/opencode/other.json']);
expect(sources.some((p) => p.includes('/home/test'))).toBe(false);

// Allowlist remains normalized for matching.
expect(plan.extraConfigs.allowlist).toContain('/home/test/.config/opencode/custom.json');
expect(plan.extraConfigs.allowlist).toContain('/home/test/.config/opencode/other.json');
});

it('keeps extra manifest sourcePath absolute when outside home dir', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
extraConfigPaths: ['/etc/hosts'],
};

const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux');

expect(plan.extraConfigs.entries.map((e) => e.sourcePath)).toEqual(['/etc/hosts']);
});

it('excludes auth files when using 1password backend', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
Expand Down
39 changes: 37 additions & 2 deletions src/sync/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,32 @@ export function expandHome(inputPath: string, homeDir: string): string {
return inputPath;
}

function collapseHome(
inputPath: string,
homeDir: string,
platform: NodeJS.Platform = process.platform
): string {
if (!inputPath) return inputPath;
if (!homeDir) return inputPath;

if (inputPath === '~' || inputPath.startsWith('~/')) return inputPath;

// Best-effort on POSIX platforms.
if (platform === 'win32') return inputPath;

const resolvedHome = path.resolve(homeDir);
const resolvedPath = path.resolve(inputPath);

if (resolvedPath === resolvedHome) return '~';

const prefix = `${resolvedHome}${path.sep}`;
if (resolvedPath.startsWith(prefix)) {
return `~/${resolvedPath.slice(prefix.length)}`;
}

return inputPath;
}

export function normalizePath(
inputPath: string,
homeDir: string,
Expand Down Expand Up @@ -315,11 +341,20 @@ function buildExtraPathPlan(
manifestPath: string,
platform: NodeJS.Platform
): ExtraPathPlan {
const allowlist = (inputPaths ?? []).map((entry) =>
// Keep sourcePath portable ("~/...") so the manifest and hashed filenames
// are stable across machines/OSes. Use a normalized allowlist for matching.
const rawPaths = (inputPaths ?? []).filter(
(entry) => typeof entry === 'string' && entry.length > 0
);
const portablePaths = rawPaths.map((entry) =>
collapseHome(entry, locations.xdg.homeDir, platform)
);

const allowlist = portablePaths.map((entry) =>
normalizePath(entry, locations.xdg.homeDir, platform)
);
Comment on lines +353 to 355
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For improved clarity, consider generating the allowlist directly from rawPaths. This would make the data flow more direct, as rawPaths would be the single source for both the runtime allowlist of absolute paths and the portablePaths for the manifest. This change improves readability without affecting functionality.

Suggested change
const allowlist = portablePaths.map((entry) =>
normalizePath(entry, locations.xdg.homeDir, platform)
);
const allowlist = rawPaths.map((entry) =>
normalizePath(entry, locations.xdg.homeDir, platform)
);


const entries = allowlist.map((sourcePath) => ({
const entries = portablePaths.map((sourcePath) => ({
sourcePath,
repoPath: path.join(repoExtraDir, encodeExtraPath(sourcePath)),
}));
Expand Down
Loading