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>
This commit is contained in:
David Kaya
2026-04-05 23:43:06 +02:00
co-authored by Copilot
parent b302ea5979
commit 059714326a
3 changed files with 26 additions and 5 deletions
+13 -3
View File
@@ -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<string>;
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<void> {
+6 -1
View File
@@ -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 <span className="text-indigo-400">{children as ReactNode}</span>;
}
return (
<a
className="text-indigo-400 underline underline-offset-2 transition hover:text-indigo-300"
href={String(href)}
href={url}
rel="noopener noreferrer"
target="_blank"
>
+7 -1
View File
@@ -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));