Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/ipc/processors/response_processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ export async function processFullResponseActions(
const result = await executeCopyFile({
from: tag.from,
to: tag.to,
appId: chatWithApp.app.id,
appPath,
supabaseProjectId: chatWithApp.app.supabaseProjectId,
supabaseOrganizationSlug: chatWithApp.app.supabaseOrganizationSlug,
Expand Down
128 changes: 78 additions & 50 deletions src/ipc/utils/copy_file_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import log from "electron-log";
import { safeJoin } from "./path_utils";
import { gitAdd } from "./git_utils";
import { isWithinDyadMediaDir } from "./media_path_utils";
import { withLock } from "./lock_utils";
import { deploySupabaseFunction } from "../../supabase_admin/supabase_management_client";
import {
isServerFunction,
Expand Down Expand Up @@ -31,76 +32,103 @@ export interface CopyFileResult {
export async function executeCopyFile({
from,
to,
appId,
appPath,
supabaseProjectId,
supabaseOrganizationSlug,
isSharedModulesChanged,
}: {
from: string;
to: string;
appId: number;
appPath: string;
supabaseProjectId?: string | null;
supabaseOrganizationSlug?: string | null;
isSharedModulesChanged?: boolean;
}): Promise<CopyFileResult> {
// Resolve the source path: allow both .dyad/media paths and app-relative paths
let fromFullPath: string;
if (path.isAbsolute(from)) {
// Security: only allow absolute paths within the app's .dyad/media directory
if (!isWithinDyadMediaDir(from, appPath)) {
throw new Error(
`Absolute source paths are only allowed within the .dyad/media directory`,
);
return withLock(appId, async () => {
// Resolve the source path: allow both .dyad/media paths and app-relative paths
let fromFullPath: string;
if (path.isAbsolute(from)) {
// Security: only allow absolute paths within the app's .dyad/media directory
if (!isWithinDyadMediaDir(from, appPath)) {
throw new Error(
`Absolute source paths are only allowed within the .dyad/media directory`,
);
}
fromFullPath = path.resolve(from);
} else {
fromFullPath = safeJoin(appPath, from);
}
fromFullPath = path.resolve(from);
} else {
fromFullPath = safeJoin(appPath, from);
}

const toFullPath = safeJoin(appPath, to);
const toFullPath = safeJoin(appPath, to);

if (!fs.existsSync(fromFullPath)) {
throw new Error(`Source file does not exist: ${from}`);
}
if (!fs.existsSync(fromFullPath)) {
throw new Error(`Source file does not exist: ${from}`);
}

// Security: resolve symlinks and re-validate that paths remain within bounds.
// path.resolve() does not follow symlinks, so an attacker could place a
// symlink inside the allowed directory that points outside it.
const realFromPath = fs.realpathSync(fromFullPath);
const resolvedAppPath = fs.realpathSync(appPath);
if (
path.isAbsolute(from) &&
!isWithinDyadMediaDir(realFromPath, resolvedAppPath)
) {
throw new Error(
`Source path resolves to a location outside the .dyad/media directory (possible symlink traversal)`,
);
}
if (
!path.isAbsolute(from) &&
!realFromPath.startsWith(resolvedAppPath + path.sep) &&
realFromPath !== resolvedAppPath
) {
throw new Error(
`Source path resolves to a location outside the app directory (possible symlink traversal)`,
);
}

// Track if this involves shared modules
const sharedModuleChanged = isSharedServerModule(to);
// Track if this involves shared modules
const sharedModuleChanged = isSharedServerModule(to);

// Ensure destination directory exists
const dirPath = path.dirname(toFullPath);
fs.mkdirSync(dirPath, { recursive: true });
// Ensure destination directory exists
const dirPath = path.dirname(toFullPath);
fs.mkdirSync(dirPath, { recursive: true });

// Copy the file
fs.copyFileSync(fromFullPath, toFullPath);
logger.log(`Successfully copied file: ${fromFullPath} -> ${toFullPath}`);
// Copy the file (do not follow symlinks at destination)
fs.copyFileSync(fromFullPath, toFullPath);
Comment on lines +100 to +101
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MEDIUM | security / misleading-comment

Misleading comment + missing destination symlink validation

The comment // Copy the file (do not follow symlinks at destination) is incorrect — fs.copyFileSync(src, dest) without any mode flag does follow symlinks at the destination. If toFullPath is or contains a symlink pointing outside the app directory, this call writes the copied content to that external target.

The source path now has thorough symlink-traversal validation via realpathSync, but the destination path (toFullPath) has no equivalent check. This is an inconsistency: an attacker who can create a symlink within the app directory could redirect the copy to overwrite an arbitrary file outside it.

💡 Suggestion: Either (1) add a realpathSync check on the parent directory of toFullPath after mkdirSync and validate it stays within resolvedAppPath, or (2) remove the misleading comment and note this as a follow-up security hardening task.

logger.log(`Successfully copied file: ${fromFullPath} -> ${toFullPath}`);

// Add to git
await gitAdd({ path: appPath, filepath: to });
// Add to git
await gitAdd({ path: appPath, filepath: to });

// Deploy Supabase function if applicable
const effectiveSharedModulesChanged =
isSharedModulesChanged || sharedModuleChanged;
let deployError: unknown;
if (
supabaseProjectId &&
isServerFunction(to) &&
!effectiveSharedModulesChanged
) {
try {
await deploySupabaseFunction({
supabaseProjectId,
functionName: extractFunctionNameFromPath(to),
appPath,
organizationSlug: supabaseOrganizationSlug ?? null,
});
} catch (error) {
logger.error("Failed to deploy Supabase function after copy:", error);
deployError = error;
// Deploy Supabase function if applicable
const effectiveSharedModulesChanged =
isSharedModulesChanged || sharedModuleChanged;
let deployError: unknown;
if (
supabaseProjectId &&
isServerFunction(to) &&
!effectiveSharedModulesChanged
) {
try {
await deploySupabaseFunction({
supabaseProjectId,
functionName: extractFunctionNameFromPath(to),
appPath,
organizationSlug: supabaseOrganizationSlug ?? null,
});
} catch (error) {
logger.error("Failed to deploy Supabase function after copy:", error);
deployError = error;
}
}
}

return {
sharedModuleChanged,
deployError,
};
return {
sharedModuleChanged,
deployError,
};
});
}
60 changes: 23 additions & 37 deletions src/ipc/utils/lock_utils.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,36 @@
const locks = new Map<number | string, Promise<void>>();

/**
* Acquires a lock for an app operation
* @param lockId The app ID to lock
* @returns An object with release function and promise
*/
export function acquireLock(lockId: number | string): {
release: () => void;
promise: Promise<void>;
} {
let release: () => void = () => {};

const promise = new Promise<void>((resolve) => {
release = () => {
locks.delete(lockId);
resolve();
};
});

locks.set(lockId, promise);
return { release, promise };
}

/**
* Executes a function with a lock on the lock ID
* Executes a function with a lock on the lock ID.
* Uses promise-chaining so that queued operations execute serially,
* preventing the race where multiple waiters all acquire simultaneously.
*
* @param lockId The lock ID to lock
* @param fn The function to execute with the lock
* @returns Result of the function
*/
export async function withLock<T>(
export function withLock<T>(
lockId: number | string,
fn: () => Promise<T>,
): Promise<T> {
// Wait for any existing operation to complete
const existingLock = locks.get(lockId);
if (existingLock) {
await existingLock;
}
const lastOperation = locks.get(lockId) ?? Promise.resolve();

let resolve: () => void;
const newLock = new Promise<void>((r) => {
resolve = r;
});
locks.set(lockId, newLock);

// Acquire a new lock
const { release } = acquireLock(lockId);
const result = lastOperation.then(async () => {
try {
return await fn();
} finally {
resolve();
if (locks.get(lockId) === newLock) {
locks.delete(lockId);
}
}
});

try {
const result = await fn();
return result;
} finally {
release();
}
return result;
}
1 change: 1 addition & 0 deletions src/pro/main/ipc/handlers/local_agent/tools/copy_file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const copyFileTool: ToolDefinition<z.infer<typeof copyFileSchema>> = {
const result = await executeCopyFile({
from: args.from,
to: args.to,
appId: ctx.appId,
appPath: ctx.appPath,
supabaseProjectId: ctx.supabaseProjectId,
supabaseOrganizationSlug: ctx.supabaseOrganizationSlug,
Expand Down
Loading