From 059714326ac8ddfc4c1762cc2cfd46577d5e2aa2 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 5 Apr 2026 23:43:06 +0200 Subject: [PATCH] fix(security): harden YAML parsing, path traversal, and markdown links - workflowSerialization: restrict YAML parser to core schema with no custom tags - gitService: validate file paths stay within project directory before I/O - chatMarkdown: allowlist URL protocols (https, http, mailto, anchors) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main/git/gitService.ts | 16 +++++++++++++--- src/renderer/lib/chatMarkdown.tsx | 7 ++++++- src/shared/domain/workflowSerialization.ts | 8 +++++++- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/main/git/gitService.ts b/src/main/git/gitService.ts index c8e5450..364a28d 100644 --- a/src/main/git/gitService.ts +++ b/src/main/git/gitService.ts @@ -2,7 +2,7 @@ import { isUtf8 } from 'node:buffer'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { promisify } from 'node:util'; import type { @@ -32,6 +32,16 @@ const { execFile } = require('node:child_process') as typeof import('node:child_ const execFileAsync = promisify(execFile); const GIT_TIMEOUT_MS = 5_000; +/** Ensure `filePath` resolves inside `basePath`; throws on traversal. */ +function assertPathInsideBase(basePath: string, filePath: string): string { + const resolved = resolve(basePath, filePath); + const rel = relative(resolve(basePath), resolved); + if (rel.startsWith('..') || isAbsolute(rel)) { + throw new Error(`Path traversal detected: "${filePath}" escapes base directory.`); + } + return resolved; +} + type GitCommandRunner = (projectPath: string, args: string[]) => Promise; type GitCommandResult = @@ -715,7 +725,7 @@ export class GitService { if (isPureUntrackedFile(file)) { try { - const contents = await readFile(join(projectPath, file.path)); + const contents = await readFile(assertPathInsideBase(projectPath, file.path)); return { path: file.path, previousPath: file.previousPath, @@ -813,7 +823,7 @@ export class GitService { throw unstageResult.error; } - await rm(join(projectPath, path), { force: true }); + await rm(assertPathInsideBase(projectPath, path), { force: true }); } private async applyPatch(projectPath: string, diff: string): Promise { diff --git a/src/renderer/lib/chatMarkdown.tsx b/src/renderer/lib/chatMarkdown.tsx index 3599d37..6706c14 100644 --- a/src/renderer/lib/chatMarkdown.tsx +++ b/src/renderer/lib/chatMarkdown.tsx @@ -100,10 +100,15 @@ export const chatMarkdownComponents: Components = { }, a({ href, children }) { + const url = String(href ?? ''); + const isSafe = /^https?:|^mailto:|^#/i.test(url); + if (!isSafe) { + return {children as ReactNode}; + } return ( diff --git a/src/shared/domain/workflowSerialization.ts b/src/shared/domain/workflowSerialization.ts index a9fe500..5414b7e 100644 --- a/src/shared/domain/workflowSerialization.ts +++ b/src/shared/domain/workflowSerialization.ts @@ -177,9 +177,15 @@ export function exportWorkflowDefinition( } } +const safeYamlOptions = { + schema: 'core' as const, + customTags: [] as [], + merge: false as const, +}; + export function importWorkflowDefinition(content: string, format: 'yaml' | 'json'): WorkflowDefinition { const parsed = format === 'yaml' - ? parseYaml(content) + ? parseYaml(content, safeYamlOptions) : JSON.parse(content) as unknown; return ensureValidWorkflow(coerceWorkflowDefinition(parsed));