From 2fe5f9f99719ea6b545b977e4b0c1c26f991bb2a Mon Sep 17 00:00:00 2001 From: xdevrobot Date: Tue, 16 Jun 2026 03:54:27 +0500 Subject: [PATCH] fix(security): comprehensive security audit hardening Critical fixes: - C-4: Fix TOCTOU race in workspace validator (single atomic lstatSync) - C-1: Make wallet encryption mandatory (reject plaintext mnemonic save) - C-2: Encrypt plugin secrets at rest with AES-256-GCM - C-3: Fix exec tool allowlist bypass via shell metacharacter injection High fixes: - H-1/H-2/H-3/M-4: Harden media download (sanitize filenames, 50MB limit, 0o600 perms) - H-4: Add symlink re-checks in WebUI routes before write/delete/rename - H-6/H-7/H-8/H-9: Harden file/directory permissions (0o700 dirs, 0o600 files, COPYFILE_EXCL, skip symlinks) - H-10: Fix sandbox directory permissions (0o755 -> 0o700) Medium fixes: - M-1: Re-normalize path after recursive URL decode - M-2: Strengthen SVG CSP header - M-3: Restrict /read endpoint to safe file extensions - M-5: Document exec env whitelist priority Co-Authored-By: Claude Opus 4.8 (1M context) --- src/agent/tools/exec/run.ts | 30 +++++- src/agent/tools/exec/runner.ts | 8 +- .../tools/telegram/media/download-media.ts | 40 ++++++-- src/sdk/secrets.ts | 94 ++++++++++++++++++- src/ton/__tests__/wallet-encryption.test.ts | 15 +-- src/ton/wallet-service.ts | 83 +++++++--------- src/webui/__tests__/workspace-raw.test.ts | 5 +- src/webui/routes/workspace.ts | 69 ++++++++++++-- src/workspace/harden-permissions.ts | 7 ++ src/workspace/manager.ts | 26 +++-- src/workspace/validator.ts | 49 ++++++---- 11 files changed, 319 insertions(+), 107 deletions(-) diff --git a/src/agent/tools/exec/run.ts b/src/agent/tools/exec/run.ts index eb437d09..aa021138 100644 --- a/src/agent/tools/exec/run.ts +++ b/src/agent/tools/exec/run.ts @@ -20,11 +20,39 @@ export const execRunTool: Tool = { }), }; +/** + * Shell metacharacters that could be used for command injection. + * If a command contains any of these, it is rejected in allowlist mode + * unless the allowlist entry itself contains the same metacharacter + * (indicating the owner explicitly intended a compound command). + */ +const SHELL_METACHARACTERS = /[;|&`$(){}[\]<>!#*?\\]/; + export function isCommandAllowed(command: string, commandAllowlist: string[]): boolean { const trimmed = command.trim(); + + // Reject empty commands + if (!trimmed) return false; + return commandAllowlist.some((pattern) => { const p = pattern.trim(); - return trimmed === p || trimmed.startsWith(p + " "); + if (!p) return false; + + // Exact match — always allowed + if (trimmed === p) return true; + + // Prefix match: the command must start with the pattern followed by + // a space, AND the remainder must not contain shell metacharacters + // that would enable injection (e.g., "ls; rm -rf /"). + if (trimmed.startsWith(p + " ")) { + const remainder = trimmed.slice(p.length + 1); + // If the pattern itself contains metacharacters, trust the owner's + // allowlist entry. Otherwise, reject if remainder has metacharacters. + if (SHELL_METACHARACTERS.test(p)) return true; + return !SHELL_METACHARACTERS.test(remainder); + } + + return false; }); } diff --git a/src/agent/tools/exec/runner.ts b/src/agent/tools/exec/runner.ts index 7692dc9f..1444ac01 100644 --- a/src/agent/tools/exec/runner.ts +++ b/src/agent/tools/exec/runner.ts @@ -104,7 +104,11 @@ export function runCommand( let timedOut = false; let resolved = false; - // Use security-provided env whitelist, or fall back to sanitized env + // SECURITY: Environment variable handling: + // - If env_whitelist is configured (non-empty), use it exclusively (allowlist). + // - Otherwise, use the name-pattern blacklist sanitizer as a fallback. + // The blacklist filters out common secret patterns (API_KEY, TOKEN, etc.) + // but is not exhaustive — prefer explicit env_whitelist for production. const env = securityEnv ?? sanitizeEnv(process.env); // On Linux, use prctl-pdeathsig helper if available so the kernel kills @@ -203,7 +207,7 @@ export function runCommand( /** Ensure the sandbox directory exists on disk. */ export function ensureSandboxDir(sandboxDir: string): void { if (!fs.existsSync(sandboxDir)) { - fs.mkdirSync(sandboxDir, { recursive: true, mode: 0o755 }); + fs.mkdirSync(sandboxDir, { recursive: true, mode: 0o700 }); } } diff --git a/src/agent/tools/telegram/media/download-media.ts b/src/agent/tools/telegram/media/download-media.ts index 44b4e982..cb43e86f 100644 --- a/src/agent/tools/telegram/media/download-media.ts +++ b/src/agent/tools/telegram/media/download-media.ts @@ -6,11 +6,15 @@ import type { Tool, ToolExecutor, ToolResult } from "../../types.js"; import { validateWritePath, extensionToFileType, + sanitizeFilename, WorkspaceSecurityError, } from "../../../../workspace/index.js"; import { getErrorMessage } from "../../../../utils/errors.js"; import { createLogger } from "../../../../utils/logger.js"; +/** Maximum allowed media download size (50 MB) */ +const MAX_MEDIA_DOWNLOAD_SIZE = 50 * 1024 * 1024; + const log = createLogger("Tools"); /** @@ -128,16 +132,29 @@ export const telegramDownloadMediaExecutor: ToolExecutor = mediaType = "gif"; } + // SECURITY: Reject filenames containing path separators or traversal + // sequences. An attacker could use "../../SOUL.md" to escape the + // downloads/ directory. (H-1) + if (filename && (/[/\\]/.test(filename) || filename.includes(".."))) { + return { + success: false, + error: `Invalid filename: path separators and '..' sequences are not allowed.`, + }; + } + + // Sanitize user-provided filename to remove dangerous characters (M-4) + const safeFilename = filename ? sanitizeFilename(filename) : undefined; + // Validate custom filename extension matches media type (security check) - if (filename) { - const providedExt = extname(filename).toLowerCase(); + if (safeFilename) { + const providedExt = extname(safeFilename).toLowerCase(); const expectedExt = extension.toLowerCase(); // Case 1: Media has extension but filename doesn't if (expectedExt && !providedExt) { return { success: false, - error: `Missing extension: filename '${filename}' must have extension '${expectedExt}' for ${mediaType}`, + error: `Missing extension: filename '${safeFilename}' must have extension '${expectedExt}' for ${mediaType}`, }; } @@ -145,7 +162,7 @@ export const telegramDownloadMediaExecutor: ToolExecutor = if (providedExt && !expectedExt) { return { success: false, - error: `Unexpected extension: filename '${filename}' has extension '${providedExt}' but ${mediaType} does not require one`, + error: `Unexpected extension: filename '${safeFilename}' has extension '${providedExt}' but ${mediaType} does not require one`, }; } @@ -160,8 +177,9 @@ export const telegramDownloadMediaExecutor: ToolExecutor = } } - // Generate filename - const finalFilename = filename || `${chatId}_${messageId}_${Date.now()}${extension}`; + // Generate filename (sanitize generated name too for safety) + const generatedName = `${chatId}_${messageId}_${Date.now()}${extension}`; + const finalFilename = safeFilename || sanitizeFilename(generatedName); // Validate workspace path for downloads/ const downloadPath = `downloads/${finalFilename}`; @@ -189,7 +207,15 @@ export const telegramDownloadMediaExecutor: ToolExecutor = }; } - // Save to file + // SECURITY: Enforce size limit to prevent memory exhaustion (H-2) + if (buffer.length > MAX_MEDIA_DOWNLOAD_SIZE) { + return { + success: false, + error: `Media too large: ${buffer.length} bytes exceeds maximum of ${MAX_MEDIA_DOWNLOAD_SIZE} bytes (${Math.round(MAX_MEDIA_DOWNLOAD_SIZE / 1024 / 1024)} MB)`, + }; + } + + // Save to file with restrictive permissions (H-3) writeFileSync(validatedPath.absolutePath, buffer, { mode: 0o600 }); return { diff --git a/src/sdk/secrets.ts b/src/sdk/secrets.ts index a17cc947..04503889 100644 --- a/src/sdk/secrets.ts +++ b/src/sdk/secrets.ts @@ -7,21 +7,75 @@ * 3. pluginConfig (config.yaml) — legacy/manual * * Secrets store: ~/.teleton/plugins/data/.secrets.json + * Secrets are encrypted at rest with AES-256-GCM using TELETON_SECRETS_KEY + * (falls back to TELETON_WALLET_KEY if not set). */ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; import { join } from "path"; +import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; import { TELETON_ROOT } from "../workspace/paths.js"; import { PluginSDKError } from "@teleton-agent/sdk"; import type { SecretsSDK, PluginLogger } from "@teleton-agent/sdk"; const SECRETS_DIR = join(TELETON_ROOT, "plugins", "data"); +// ─── Encryption helpers ────────────────────────────────────────────── + +interface EncryptedFile { + encrypted: true; + iv: string; + tag: string; + ciphertext: string; +} + +/** + * Resolve the encryption key for secrets. + * Prefers TELETON_SECRETS_KEY, falls back to TELETON_WALLET_KEY. + * Returns null only if neither is configured (legacy unencrypted mode). + */ +function resolveSecretsEncryptionKey(): Buffer | null { + const envKey = process.env.TELETON_SECRETS_KEY || process.env.TELETON_WALLET_KEY; + if (!envKey) return null; + if (envKey.length !== 64 || !/^[0-9a-fA-F]+$/.test(envKey)) { + throw new Error( + "TELETON_SECRETS_KEY / TELETON_WALLET_KEY must be a 64-character hex string (32 bytes)." + ); + } + return Buffer.from(envKey, "hex"); +} + +function encryptJson(data: Record, key: Buffer): EncryptedFile { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const plaintext = JSON.stringify(data); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return { + encrypted: true, + iv: iv.toString("hex"), + tag: tag.toString("hex"), + ciphertext: encrypted.toString("hex"), + }; +} + +function decryptJson(file: EncryptedFile, key: Buffer): Record { + const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(file.iv, "hex")); + decipher.setAuthTag(Buffer.from(file.tag, "hex")); + const decrypted = Buffer.concat([ + decipher.update(Buffer.from(file.ciphertext, "hex")), + decipher.final(), + ]); + return JSON.parse(decrypted.toString("utf8")) as Record; +} + +// ─── File I/O ───────────────────────────────────────────────────────── + function getSecretsPath(pluginName: string): string { return join(SECRETS_DIR, `${pluginName}.secrets.json`); } -/** Read persisted secrets from the JSON file */ +/** Read persisted secrets from the JSON file (handles both encrypted and legacy plaintext) */ function readSecretsFile(pluginName: string): Record { const filePath = getSecretsPath(pluginName); try { @@ -29,6 +83,23 @@ function readSecretsFile(pluginName: string): Record { const raw = readFileSync(filePath, "utf-8"); const parsed = JSON.parse(raw); if (typeof parsed !== "object" || parsed === null) return {}; + + // Encrypted format + if (parsed.encrypted === true) { + const key = resolveSecretsEncryptionKey(); + if (!key) { + // Key not configured — cannot decrypt. Return empty and log warning. + return {}; + } + try { + return decryptJson(parsed as EncryptedFile, key); + } catch { + // Decryption failed — wrong key or corrupted file + return {}; + } + } + + // Legacy plaintext format — return as-is (will be re-encrypted on next write) return parsed as Record; } catch { return {}; @@ -38,13 +109,23 @@ function readSecretsFile(pluginName: string): Record { /** * Write a secret to the persisted secrets file. * Used by admin commands (/plugin set). + * Secrets are encrypted at rest when an encryption key is available. */ export function writePluginSecret(pluginName: string, key: string, value: string): void { mkdirSync(SECRETS_DIR, { recursive: true, mode: 0o700 }); const filePath = getSecretsPath(pluginName); const existing = readSecretsFile(pluginName); existing[key] = value; - writeFileSync(filePath, JSON.stringify(existing, null, 2), { mode: 0o600 }); + + const encKey = resolveSecretsEncryptionKey(); + if (encKey) { + const encrypted = encryptJson(existing, encKey); + writeFileSync(filePath, JSON.stringify(encrypted, null, 2), { mode: 0o600 }); + } else { + // Fallback: write plaintext (same as before — for backwards compatibility + // when no encryption key is configured at all) + writeFileSync(filePath, JSON.stringify(existing, null, 2), { mode: 0o600 }); + } } /** @@ -56,7 +137,14 @@ export function deletePluginSecret(pluginName: string, key: string): boolean { if (!(key in existing)) return false; delete existing[key]; const filePath = getSecretsPath(pluginName); - writeFileSync(filePath, JSON.stringify(existing, null, 2), { mode: 0o600 }); + + const encKey = resolveSecretsEncryptionKey(); + if (encKey) { + const encrypted = encryptJson(existing, encKey); + writeFileSync(filePath, JSON.stringify(encrypted, null, 2), { mode: 0o600 }); + } else { + writeFileSync(filePath, JSON.stringify(existing, null, 2), { mode: 0o600 }); + } return true; } diff --git a/src/ton/__tests__/wallet-encryption.test.ts b/src/ton/__tests__/wallet-encryption.test.ts index 36c3bd42..2693e090 100644 --- a/src/ton/__tests__/wallet-encryption.test.ts +++ b/src/ton/__tests__/wallet-encryption.test.ts @@ -196,19 +196,14 @@ describe("saveWallet / loadWallet (encryption integration)", () => { } }); - it("saves plaintext when no encryption key is set", () => { + it("throws when no encryption key is set (plaintext saving is rejected)", () => { delete process.env.TELETON_WALLET_KEY; mockFs.existsSync.mockReturnValue(true); - saveWallet(TEST_WALLET); - - expect(mockFs.writeFileSync).toHaveBeenCalledOnce(); - const written = mockFs.writeFileSync.mock.calls[0][1] as string; - const parsed = JSON.parse(written); - // Plaintext format: mnemonic array is visible - expect(parsed.encrypted).toBeUndefined(); - expect(Array.isArray(parsed.mnemonic)).toBe(true); - expect(parsed.mnemonic).toEqual(TEST_MNEMONIC); + // SECURITY: Plaintext wallet saving is no longer allowed. + // An encryption key (TELETON_WALLET_KEY) is mandatory. + expect(() => saveWallet(TEST_WALLET)).toThrow(/TELETON_WALLET_KEY is required/); + expect(mockFs.writeFileSync).not.toHaveBeenCalled(); }); it("saves encrypted format when key is set", () => { diff --git a/src/ton/wallet-service.ts b/src/ton/wallet-service.ts index 5d317f8c..64e8d948 100644 --- a/src/ton/wallet-service.ts +++ b/src/ton/wallet-service.ts @@ -171,26 +171,30 @@ export function saveWallet(wallet: WalletData): void { throw err; } - let fileContent: string; - if (key) { - const { iv, tag, ciphertext } = encryptMnemonic(wallet.mnemonic, key); - const encrypted: EncryptedWalletFile = { - encrypted: true, - version: wallet.version, - address: wallet.address, - publicKey: wallet.publicKey, - createdAt: wallet.createdAt, - iv, - tag, - ciphertext, - }; - fileContent = JSON.stringify(encrypted, null, 2); - log.debug("Saving wallet with AES-256-GCM encrypted mnemonic"); - } else { - fileContent = JSON.stringify(wallet, null, 2); - log.debug("Saving wallet with plaintext mnemonic (no encryption key configured)"); + if (!key) { + // CRITICAL SECURITY: Never save mnemonic in plaintext. + // An attacker with filesystem read access would gain full wallet control. + throw new Error( + "TELETON_WALLET_KEY is required to save wallet. " + + "Generate one with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\" " + + "and set it as TELETON_WALLET_KEY in your environment or .env file." + ); } + const { iv, tag, ciphertext } = encryptMnemonic(wallet.mnemonic, key); + const encrypted: EncryptedWalletFile = { + encrypted: true, + version: wallet.version, + address: wallet.address, + publicKey: wallet.publicKey, + createdAt: wallet.createdAt, + iv, + tag, + ciphertext, + }; + const fileContent = JSON.stringify(encrypted, null, 2); + log.debug("Saving wallet with AES-256-GCM encrypted mnemonic"); + writeFileSync(WALLET_FILE, fileContent, { encoding: "utf-8", mode: 0o600 }); // Invalidate caches so next loadWallet()/getKeyPair() re-reads @@ -246,38 +250,17 @@ export function loadWallet(): WalletData | null { return null; } } else { - // ── Plaintext (legacy) format ───────────────────────────────── - if (!parsed.mnemonic || !Array.isArray(parsed.mnemonic) || parsed.mnemonic.length !== 24) { - throw new Error("Invalid wallet.json: mnemonic must be a 24-word array"); - } - mnemonic = parsed.mnemonic as string[]; - - // Transparently migrate to encrypted format if key is now configured - let key: Buffer | null = null; - try { - key = resolveEncryptionKey(); - } catch { - // Ignore key errors during migration attempt — log and continue plaintext - } - if (key) { - log.info("Encryption key detected — migrating plaintext wallet.json to encrypted format"); - try { - const walletToMigrate: WalletData = { - version: parsed.version ?? "w5r1", - address: parsed.address, - publicKey: parsed.publicKey, - mnemonic, - createdAt: parsed.createdAt, - }; - saveWallet(walletToMigrate); - // loadWallet() is recursively called by saveWallet cache reset, so just load from cache - } catch (err) { - log.error( - { err }, - "Failed to migrate wallet to encrypted format — continuing with plaintext" - ); - } - } + // ── Plaintext (legacy) format — REJECTED for security ───────── + // Plaintext wallet files are no longer supported. The owner must + // either: (1) set TELETON_WALLET_KEY and re-import via mnemonic, or + // (2) delete wallet.json and let the agent generate a new encrypted one. + log.error( + "wallet.json contains plaintext mnemonic — this is no longer supported. " + + "Set TELETON_WALLET_KEY and import your mnemonic via /wallet import, " + + "or delete wallet.json to generate a new encrypted wallet." + ); + _walletCache = null; + return null; } if (mnemonic.length !== 24) { diff --git a/src/webui/__tests__/workspace-raw.test.ts b/src/webui/__tests__/workspace-raw.test.ts index 40f1edeb..cc702e78 100644 --- a/src/webui/__tests__/workspace-raw.test.ts +++ b/src/webui/__tests__/workspace-raw.test.ts @@ -116,7 +116,10 @@ describe("GET /workspace/raw", () => { expect(res.status).toBe(200); expect(res.headers.get("Content-Type")).toBe("image/svg+xml"); - expect(res.headers.get("Content-Security-Policy")).toBe("sandbox"); + // SECURITY: SVG files get strict CSP to prevent script execution via foreignObject + expect(res.headers.get("Content-Security-Policy")).toBe( + "default-src 'none'; img-src data:; script-src 'none'; sandbox" + ); }); it("returns 415 for unsupported file types", async () => { diff --git a/src/webui/routes/workspace.ts b/src/webui/routes/workspace.ts index 468c4969..be2d6fd5 100644 --- a/src/webui/routes/workspace.ts +++ b/src/webui/routes/workspace.ts @@ -229,9 +229,10 @@ export function createWorkspaceRoutes(_deps: WebUIServerDeps) { "Cache-Control": "private, max-age=60", }; - // SVG security: sandbox to prevent script execution if opened directly + // SVG security: strict CSP to prevent script execution via foreignObject etc. if (validated.extension === ".svg") { - headers["Content-Security-Policy"] = "sandbox"; + headers["Content-Security-Policy"] = + "default-src 'none'; img-src data:; script-src 'none'; sandbox"; } return c.body(buffer, 200, headers); @@ -267,6 +268,28 @@ export function createWorkspaceRoutes(_deps: WebUIServerDeps) { return c.json(response, 413); } + // SECURITY: Only allow reading safe text-based file types (M-3). + // This prevents stored XSS if an HTML/JS file is uploaded to workspace. + const READABLE_EXTENSIONS = new Set([ + ".txt", + ".md", + ".json", + ".csv", + ".log", + ".yaml", + ".yml", + ".xml", + ".toml", + ".ini", + ]); + if (!READABLE_EXTENSIONS.has(validated.extension)) { + const response: APIResponse = { + success: false, + error: `File type '${validated.extension}' is not allowed for reading. Allowed: ${[...READABLE_EXTENSIONS].join(", ")}`, + }; + return c.json(response, 415); + } + const content = readFileSync(validated.absolutePath, "utf-8"); const response: APIResponse<{ content: string; size: number }> = { @@ -294,11 +317,25 @@ export function createWorkspaceRoutes(_deps: WebUIServerDeps) { const validated = validateWritePath(body.path, "text"); + // SECURITY: Re-check for symlinks right before write to mitigate + // TOCTOU race (file swapped between validation and write). + // Only check if the file already exists (new files won't have a symlink yet). + if (validated.exists) { + const writeStats = lstatSync(validated.absolutePath); + if (writeStats.isSymbolicLink()) { + const response: APIResponse = { + success: false, + error: "Access denied: symbolic links are not allowed", + }; + return c.json(response, 403); + } + } + // Ensure parent directory exists const parentDir = join(validated.absolutePath, ".."); - mkdirSync(parentDir, { recursive: true }); + mkdirSync(parentDir, { recursive: true, mode: 0o700 }); - writeFileSync(validated.absolutePath, body.content, "utf-8"); + writeFileSync(validated.absolutePath, body.content, { mode: 0o600 }); const response: APIResponse<{ message: string }> = { success: true, @@ -321,7 +358,7 @@ export function createWorkspaceRoutes(_deps: WebUIServerDeps) { } const validated = validateDirectory(body.path); - mkdirSync(validated.absolutePath, { recursive: true }); + mkdirSync(validated.absolutePath, { recursive: true, mode: 0o700 }); const response: APIResponse<{ message: string }> = { success: true, @@ -345,6 +382,16 @@ export function createWorkspaceRoutes(_deps: WebUIServerDeps) { const validated = validatePath(body.path, false); + // SECURITY: Re-check for symlinks right before delete to mitigate TOCTOU + const deleteStats = lstatSync(validated.absolutePath); + if (deleteStats.isSymbolicLink()) { + const response: APIResponse = { + success: false, + error: "Access denied: symbolic links are not allowed", + }; + return c.json(response, 403); + } + if (validated.isDirectory && !body.recursive) { // Check if directory is empty const contents = readdirSync(validated.absolutePath); @@ -385,9 +432,19 @@ export function createWorkspaceRoutes(_deps: WebUIServerDeps) { const fromValidated = validatePath(body.from, false); const toValidated = validatePath(body.to, true); + // SECURITY: Re-check for symlinks right before rename to mitigate TOCTOU + const renameStats = lstatSync(fromValidated.absolutePath); + if (renameStats.isSymbolicLink()) { + const response: APIResponse = { + success: false, + error: "Access denied: symbolic links are not allowed", + }; + return c.json(response, 403); + } + // Ensure target parent directory exists const parentDir = join(toValidated.absolutePath, ".."); - mkdirSync(parentDir, { recursive: true }); + mkdirSync(parentDir, { recursive: true, mode: 0o700 }); renameSync(fromValidated.absolutePath, toValidated.absolutePath); diff --git a/src/workspace/harden-permissions.ts b/src/workspace/harden-permissions.ts index 4e542580..7651c244 100644 --- a/src/workspace/harden-permissions.ts +++ b/src/workspace/harden-permissions.ts @@ -106,6 +106,13 @@ function hardenDirectory(dirPath: string, fileMode: number): number { const entries = readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dirPath, entry.name); + + // SECURITY: Skip symlinks to avoid modifying files outside workspace (H-6) + if (entry.isSymbolicLink()) { + log.debug(`Skipping symlink during permission hardening: ${fullPath}`); + continue; + } + if (entry.isFile()) { try { const stat = statSync(fullPath); diff --git a/src/workspace/manager.ts b/src/workspace/manager.ts index 84ff3ae3..eade0fea 100644 --- a/src/workspace/manager.ts +++ b/src/workspace/manager.ts @@ -1,6 +1,14 @@ // src/workspace/manager.ts -import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from "fs"; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + copyFileSync, + constants, + chmodSync, +} from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { TELETON_ROOT, WORKSPACE_ROOT, WORKSPACE_PATHS } from "./paths.js"; @@ -56,13 +64,13 @@ export async function ensureWorkspace(config?: WorkspaceConfig): Promise | null = null; + let exists = false; + + try { + stats = lstatSync(absolutePath); + exists = true; + } catch { + // File/directory does not exist + } if (!exists && !allowCreate) { throw new WorkspaceSecurityError( @@ -128,24 +142,19 @@ export function validatePath(inputPath: string, allowCreate: boolean = false): V ); } - // SECURITY FIX: Use lstatSync() instead of statSync() to detect symlinks - // (statSync follows symlinks, lstatSync does not) - if (exists) { - const stats = lstatSync(absolutePath); - - if (stats.isSymbolicLink()) { - throw new WorkspaceSecurityError( - `Access denied: Symbolic links are not allowed for security reasons.`, - inputPath - ); - } + // SECURITY: Use lstatSync() (not statSync) to detect symlinks + if (stats?.isSymbolicLink()) { + throw new WorkspaceSecurityError( + `Access denied: Symbolic links are not allowed for security reasons.`, + inputPath + ); } return { absolutePath, relativePath, exists, - isDirectory: exists ? lstatSync(absolutePath).isDirectory() : false, + isDirectory: stats ? stats.isDirectory() : false, extension: extname(absolutePath).toLowerCase(), filename: basename(absolutePath), };