Unverified 提交 262c2052 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Prevent concurrent file copying (#2841)

closes #2804 <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2841" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 6f53e896
...@@ -423,6 +423,7 @@ export async function processFullResponseActions( ...@@ -423,6 +423,7 @@ export async function processFullResponseActions(
const result = await executeCopyFile({ const result = await executeCopyFile({
from: tag.from, from: tag.from,
to: tag.to, to: tag.to,
appId: chatWithApp.app.id,
appPath, appPath,
supabaseProjectId: chatWithApp.app.supabaseProjectId, supabaseProjectId: chatWithApp.app.supabaseProjectId,
supabaseOrganizationSlug: chatWithApp.app.supabaseOrganizationSlug, supabaseOrganizationSlug: chatWithApp.app.supabaseOrganizationSlug,
......
...@@ -4,6 +4,7 @@ import log from "electron-log"; ...@@ -4,6 +4,7 @@ import log from "electron-log";
import { safeJoin } from "./path_utils"; import { safeJoin } from "./path_utils";
import { gitAdd } from "./git_utils"; import { gitAdd } from "./git_utils";
import { isWithinDyadMediaDir } from "./media_path_utils"; import { isWithinDyadMediaDir } from "./media_path_utils";
import { withLock } from "./lock_utils";
import { deploySupabaseFunction } from "../../supabase_admin/supabase_management_client"; import { deploySupabaseFunction } from "../../supabase_admin/supabase_management_client";
import { import {
isServerFunction, isServerFunction,
...@@ -31,6 +32,7 @@ export interface CopyFileResult { ...@@ -31,6 +32,7 @@ export interface CopyFileResult {
export async function executeCopyFile({ export async function executeCopyFile({
from, from,
to, to,
appId,
appPath, appPath,
supabaseProjectId, supabaseProjectId,
supabaseOrganizationSlug, supabaseOrganizationSlug,
...@@ -38,11 +40,13 @@ export async function executeCopyFile({ ...@@ -38,11 +40,13 @@ export async function executeCopyFile({
}: { }: {
from: string; from: string;
to: string; to: string;
appId: number;
appPath: string; appPath: string;
supabaseProjectId?: string | null; supabaseProjectId?: string | null;
supabaseOrganizationSlug?: string | null; supabaseOrganizationSlug?: string | null;
isSharedModulesChanged?: boolean; isSharedModulesChanged?: boolean;
}): Promise<CopyFileResult> { }): Promise<CopyFileResult> {
return withLock(appId, async () => {
// Resolve the source path: allow both .dyad/media paths and app-relative paths // Resolve the source path: allow both .dyad/media paths and app-relative paths
let fromFullPath: string; let fromFullPath: string;
if (path.isAbsolute(from)) { if (path.isAbsolute(from)) {
...@@ -63,6 +67,29 @@ export async function executeCopyFile({ ...@@ -63,6 +67,29 @@ export async function executeCopyFile({
throw new Error(`Source file does not exist: ${from}`); 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 // Track if this involves shared modules
const sharedModuleChanged = isSharedServerModule(to); const sharedModuleChanged = isSharedServerModule(to);
...@@ -70,7 +97,7 @@ export async function executeCopyFile({ ...@@ -70,7 +97,7 @@ export async function executeCopyFile({
const dirPath = path.dirname(toFullPath); const dirPath = path.dirname(toFullPath);
fs.mkdirSync(dirPath, { recursive: true }); fs.mkdirSync(dirPath, { recursive: true });
// Copy the file // Copy the file (do not follow symlinks at destination)
fs.copyFileSync(fromFullPath, toFullPath); fs.copyFileSync(fromFullPath, toFullPath);
logger.log(`Successfully copied file: ${fromFullPath} -> ${toFullPath}`); logger.log(`Successfully copied file: ${fromFullPath} -> ${toFullPath}`);
...@@ -103,4 +130,5 @@ export async function executeCopyFile({ ...@@ -103,4 +130,5 @@ export async function executeCopyFile({
sharedModuleChanged, sharedModuleChanged,
deployError, deployError,
}; };
});
} }
const locks = new Map<number | string, Promise<void>>(); const locks = new Map<number | string, Promise<void>>();
/** /**
* Acquires a lock for an app operation * Executes a function with a lock on the lock ID.
* @param lockId The app ID to lock * Uses promise-chaining so that queued operations execute serially,
* @returns An object with release function and promise * preventing the race where multiple waiters all acquire simultaneously.
*/ *
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
* @param lockId The lock ID to lock * @param lockId The lock ID to lock
* @param fn The function to execute with the lock * @param fn The function to execute with the lock
* @returns Result of the function * @returns Result of the function
*/ */
export async function withLock<T>( export function withLock<T>(
lockId: number | string, lockId: number | string,
fn: () => Promise<T>, fn: () => Promise<T>,
): Promise<T> { ): Promise<T> {
// Wait for any existing operation to complete const lastOperation = locks.get(lockId) ?? Promise.resolve();
const existingLock = locks.get(lockId);
if (existingLock) {
await existingLock;
}
// Acquire a new lock let resolve: () => void;
const { release } = acquireLock(lockId); const newLock = new Promise<void>((r) => {
resolve = r;
});
locks.set(lockId, newLock);
const result = lastOperation.then(async () => {
try { try {
const result = await fn(); return await fn();
return result;
} finally { } finally {
release(); resolve();
if (locks.get(lockId) === newLock) {
locks.delete(lockId);
} }
}
});
return result;
} }
...@@ -34,6 +34,7 @@ export const copyFileTool: ToolDefinition<z.infer<typeof copyFileSchema>> = { ...@@ -34,6 +34,7 @@ export const copyFileTool: ToolDefinition<z.infer<typeof copyFileSchema>> = {
const result = await executeCopyFile({ const result = await executeCopyFile({
from: args.from, from: args.from,
to: args.to, to: args.to,
appId: ctx.appId,
appPath: ctx.appPath, appPath: ctx.appPath,
supabaseProjectId: ctx.supabaseProjectId, supabaseProjectId: ctx.supabaseProjectId,
supabaseOrganizationSlug: ctx.supabaseOrganizationSlug, supabaseOrganizationSlug: ctx.supabaseOrganizationSlug,
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论