Skip to content

Commit 2a254f2

Browse files
committed
fix(symphony): Address security and error handling issues from PR review
- Add path traversal prevention: sanitize repo names, validate document paths - Add GitHub URL validation (HTTPS only, github.com only) - Add repository slug format validation - Add gh CLI authentication check before PR operations - Add default branch detection instead of hardcoded 'main' - Add remote branch cleanup on PR creation failure - Fix ReDoS vulnerability in document path regex patterns - Improve error logging throughout handlers
1 parent 67bb07b commit 2a254f2

2 files changed

Lines changed: 256 additions & 17 deletions

File tree

src/main/ipc/handlers/symphony.ts

Lines changed: 248 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,114 @@ import { SymphonyError } from '../../../shared/symphony-types';
5252

5353
const LOG_CONTEXT = '[Symphony]';
5454

55+
// ============================================================================
56+
// Validation Helpers
57+
// ============================================================================
58+
59+
/**
60+
* Sanitize repository name to prevent path traversal attacks.
61+
* Removes any characters that could be used for path traversal.
62+
*/
63+
function sanitizeRepoName(repoName: string): string {
64+
// Only allow alphanumeric, dashes, underscores, and dots (not leading)
65+
return repoName
66+
.replace(/\.\./g, '') // Remove path traversal sequences
67+
.replace(/[^a-zA-Z0-9_\-]/g, '-') // Replace unsafe chars with dashes
68+
.replace(/^\.+/, '') // Remove leading dots
69+
.substring(0, 100); // Limit length
70+
}
71+
72+
/**
73+
* Validate that a URL is a GitHub repository URL.
74+
* Only allows HTTPS URLs to github.com.
75+
*/
76+
function validateGitHubUrl(url: string): { valid: boolean; error?: string } {
77+
try {
78+
const parsed = new URL(url);
79+
if (parsed.protocol !== 'https:') {
80+
return { valid: false, error: 'Only HTTPS URLs are allowed' };
81+
}
82+
if (parsed.hostname !== 'github.com' && parsed.hostname !== 'www.github.com') {
83+
return { valid: false, error: 'Only GitHub repositories are allowed' };
84+
}
85+
// Check for valid repo path format (owner/repo)
86+
const pathParts = parsed.pathname.split('/').filter(Boolean);
87+
if (pathParts.length < 2) {
88+
return { valid: false, error: 'Invalid repository path' };
89+
}
90+
return { valid: true };
91+
} catch {
92+
return { valid: false, error: 'Invalid URL format' };
93+
}
94+
}
95+
96+
/**
97+
* Validate repository slug format (owner/repo).
98+
*/
99+
function validateRepoSlug(slug: string): { valid: boolean; error?: string } {
100+
if (!slug || typeof slug !== 'string') {
101+
return { valid: false, error: 'Repository slug is required' };
102+
}
103+
const parts = slug.split('/');
104+
if (parts.length !== 2) {
105+
return { valid: false, error: 'Invalid repository slug format (expected owner/repo)' };
106+
}
107+
const [owner, repo] = parts;
108+
if (!owner || !repo) {
109+
return { valid: false, error: 'Owner and repository name are required' };
110+
}
111+
// GitHub username/repo name rules
112+
if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(owner)) {
113+
return { valid: false, error: 'Invalid owner name' };
114+
}
115+
if (!/^[a-zA-Z0-9._-]+$/.test(repo)) {
116+
return { valid: false, error: 'Invalid repository name' };
117+
}
118+
return { valid: true };
119+
}
120+
121+
/**
122+
* Validate contribution start parameters.
123+
*/
124+
function validateContributionParams(params: {
125+
repoSlug: string;
126+
repoUrl: string;
127+
repoName: string;
128+
issueNumber: number;
129+
documentPaths: string[];
130+
}): { valid: boolean; error?: string } {
131+
// Validate repo slug
132+
const slugValidation = validateRepoSlug(params.repoSlug);
133+
if (!slugValidation.valid) {
134+
return slugValidation;
135+
}
136+
137+
// Validate URL
138+
const urlValidation = validateGitHubUrl(params.repoUrl);
139+
if (!urlValidation.valid) {
140+
return urlValidation;
141+
}
142+
143+
// Validate repo name
144+
if (!params.repoName || typeof params.repoName !== 'string') {
145+
return { valid: false, error: 'Repository name is required' };
146+
}
147+
148+
// Validate issue number
149+
if (!Number.isInteger(params.issueNumber) || params.issueNumber <= 0) {
150+
return { valid: false, error: 'Invalid issue number' };
151+
}
152+
153+
// Validate document paths (check for path traversal)
154+
for (const docPath of params.documentPaths) {
155+
if (docPath.includes('..') || docPath.startsWith('/')) {
156+
return { valid: false, error: `Invalid document path: ${docPath}` };
157+
}
158+
}
159+
160+
return { valid: true };
161+
}
162+
55163
// ============================================================================
56164
// Dependencies Interface
57165
// ============================================================================
@@ -331,6 +439,53 @@ async function createBranch(
331439
return { success: true };
332440
}
333441

442+
/**
443+
* Check if gh CLI is authenticated.
444+
*/
445+
async function checkGhAuthentication(): Promise<{ authenticated: boolean; error?: string }> {
446+
const result = await execFileNoThrow('gh', ['auth', 'status']);
447+
if (result.exitCode !== 0) {
448+
// gh auth status outputs to stderr even on success for some info
449+
const output = result.stderr + result.stdout;
450+
if (output.includes('not logged in') || output.includes('no accounts')) {
451+
return { authenticated: false, error: 'GitHub CLI is not authenticated. Run "gh auth login" to authenticate.' };
452+
}
453+
// If gh CLI is not installed
454+
if (output.includes('command not found') || output.includes('not recognized')) {
455+
return { authenticated: false, error: 'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/' };
456+
}
457+
return { authenticated: false, error: `GitHub CLI error: ${output}` };
458+
}
459+
return { authenticated: true };
460+
}
461+
462+
/**
463+
* Get the default branch of a repository.
464+
*/
465+
async function getDefaultBranch(repoPath: string): Promise<string> {
466+
// Try to get the default branch from remote
467+
const result = await execFileNoThrow('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], repoPath);
468+
if (result.exitCode === 0) {
469+
// Output is like "refs/remotes/origin/main"
470+
const branch = result.stdout.trim().replace('refs/remotes/origin/', '');
471+
if (branch) return branch;
472+
}
473+
474+
// Fallback: try common branch names
475+
const checkResult = await execFileNoThrow('git', ['ls-remote', '--heads', 'origin', 'main'], repoPath);
476+
if (checkResult.exitCode === 0 && checkResult.stdout.includes('refs/heads/main')) {
477+
return 'main';
478+
}
479+
480+
const masterCheck = await execFileNoThrow('git', ['ls-remote', '--heads', 'origin', 'master'], repoPath);
481+
if (masterCheck.exitCode === 0 && masterCheck.stdout.includes('refs/heads/master')) {
482+
return 'master';
483+
}
484+
485+
// Default to main if we can't determine
486+
return 'main';
487+
}
488+
334489
/**
335490
* Push branch and create draft PR using gh CLI.
336491
*/
@@ -340,6 +495,12 @@ async function createDraftPR(
340495
title: string,
341496
body: string
342497
): Promise<{ success: boolean; prUrl?: string; prNumber?: number; error?: string }> {
498+
// Check gh authentication first
499+
const authCheck = await checkGhAuthentication();
500+
if (!authCheck.authenticated) {
501+
return { success: false, error: authCheck.error };
502+
}
503+
343504
// First push the branch
344505
const pushResult = await execFileNoThrow('git', ['push', '-u', 'origin', 'HEAD'], repoPath);
345506

@@ -355,6 +516,9 @@ async function createDraftPR(
355516
);
356517

357518
if (prResult.exitCode !== 0) {
519+
// If PR creation failed after push, try to delete the remote branch
520+
logger.warn('PR creation failed, attempting to clean up remote branch', LOG_CONTEXT);
521+
await execFileNoThrow('git', ['push', 'origin', '--delete', 'HEAD'], repoPath);
358522
return { success: false, error: `Failed to create PR: ${prResult.stderr}` };
359523
}
360524

@@ -592,6 +756,24 @@ export function registerSymphonyHandlers({ app, getMainWindow }: SymphonyHandler
592756
sessionId: string;
593757
baseBranch?: string;
594758
}): Promise<Omit<StartContributionResponse, 'success'>> => {
759+
// Validate input parameters
760+
const validation = validateContributionParams({
761+
repoSlug: params.repoSlug,
762+
repoUrl: params.repoUrl,
763+
repoName: params.repoName,
764+
issueNumber: params.issueNumber,
765+
documentPaths: params.documentPaths,
766+
});
767+
if (!validation.valid) {
768+
return { error: validation.error };
769+
}
770+
771+
// Check gh CLI authentication before starting
772+
const authCheck = await checkGhAuthentication();
773+
if (!authCheck.authenticated) {
774+
return { error: authCheck.error };
775+
}
776+
595777
const {
596778
repoSlug,
597779
repoUrl,
@@ -601,7 +783,6 @@ export function registerSymphonyHandlers({ app, getMainWindow }: SymphonyHandler
601783
documentPaths,
602784
agentType,
603785
sessionId,
604-
baseBranch = 'main',
605786
} = params;
606787

607788
const contributionId = generateContributionId();
@@ -617,10 +798,13 @@ export function registerSymphonyHandlers({ app, getMainWindow }: SymphonyHandler
617798
};
618799
}
619800

801+
// Sanitize repo name for local path
802+
const sanitizedRepoName = sanitizeRepoName(repoName);
803+
620804
// Determine local path
621805
const reposDir = getReposDir(app);
622806
await fs.mkdir(reposDir, { recursive: true });
623-
const localPath = path.join(reposDir, `${repoName}-${contributionId}`);
807+
const localPath = path.join(reposDir, `${sanitizedRepoName}-${contributionId}`);
624808

625809
// Generate branch name
626810
const branchName = generateBranchName(issueNumber);
@@ -631,6 +815,9 @@ export function registerSymphonyHandlers({ app, getMainWindow }: SymphonyHandler
631815
return { error: `Clone failed: ${cloneResult.error}` };
632816
}
633817

818+
// Detect default branch (don't rely on hardcoded 'main')
819+
const baseBranch = params.baseBranch || await getDefaultBranch(localPath);
820+
634821
// Create branch
635822
const branchResult = await createBranch(localPath, branchName);
636823
if (!branchResult.success) {
@@ -927,6 +1114,12 @@ This PR will be updated automatically when the Auto Run completes.`;
9271114
async (params: { repoUrl: string; localPath: string }): Promise<{ success: boolean; error?: string }> => {
9281115
const { repoUrl, localPath } = params;
9291116

1117+
// Validate GitHub URL
1118+
const urlValidation = validateGitHubUrl(repoUrl);
1119+
if (!urlValidation.valid) {
1120+
return { success: false, error: urlValidation.error };
1121+
}
1122+
9301123
// Ensure parent directory exists
9311124
const parentDir = path.dirname(localPath);
9321125
await fs.mkdir(parentDir, { recursive: true });
@@ -967,57 +1160,98 @@ This PR will be updated automatically when the Auto Run completes.`;
9671160
autoRunPath?: string;
9681161
error?: string;
9691162
}> => {
970-
const { contributionId, sessionId, repoSlug: _repoSlug, issueNumber, issueTitle, localPath, documentPaths } = params;
1163+
const { contributionId, sessionId, repoSlug, issueNumber, issueTitle, localPath, documentPaths } = params;
1164+
1165+
// Validate inputs
1166+
const slugValidation = validateRepoSlug(repoSlug);
1167+
if (!slugValidation.valid) {
1168+
return { success: false, error: slugValidation.error };
1169+
}
1170+
1171+
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
1172+
return { success: false, error: 'Invalid issue number' };
1173+
}
1174+
1175+
// Validate document paths for path traversal
1176+
for (const docPath of documentPaths) {
1177+
if (docPath.includes('..') || docPath.startsWith('/')) {
1178+
return { success: false, error: `Invalid document path: ${docPath}` };
1179+
}
1180+
}
1181+
1182+
// Check gh CLI authentication
1183+
const authCheck = await checkGhAuthentication();
1184+
if (!authCheck.authenticated) {
1185+
return { success: false, error: authCheck.error };
1186+
}
9711187

9721188
try {
9731189
// 1. Create branch
9741190
const branchName = generateBranchName(issueNumber);
9751191
const branchResult = await createBranch(localPath, branchName);
9761192
if (!branchResult.success) {
977-
return { success: false, error: 'Failed to create branch' };
1193+
logger.error('Failed to create branch', LOG_CONTEXT, { localPath, branchName, error: branchResult.error });
1194+
return { success: false, error: `Failed to create branch: ${branchResult.error}` };
9781195
}
9791196

9801197
// 2. Empty commit to enable push
9811198
const commitMessage = `[Symphony] Start contribution for #${issueNumber}`;
982-
await execFileNoThrow('git', ['commit', '--allow-empty', '-m', commitMessage], localPath);
1199+
const commitResult = await execFileNoThrow('git', ['commit', '--allow-empty', '-m', commitMessage], localPath);
1200+
if (commitResult.exitCode !== 0) {
1201+
logger.error('Failed to create empty commit', LOG_CONTEXT, { localPath, error: commitResult.stderr });
1202+
}
9831203

9841204
// 3. Push branch
9851205
const pushResult = await execFileNoThrow('git', ['push', '-u', 'origin', branchName], localPath);
9861206
if (pushResult.exitCode !== 0) {
987-
return { success: false, error: 'Failed to push branch' };
1207+
logger.error('Failed to push branch', LOG_CONTEXT, { localPath, branchName, error: pushResult.stderr });
1208+
return { success: false, error: `Failed to push branch: ${pushResult.stderr}` };
9881209
}
9891210

990-
// 4. Create draft PR
1211+
// 4. Detect default branch for PR base
1212+
const baseBranch = await getDefaultBranch(localPath);
1213+
1214+
// 5. Create draft PR
9911215
const prTitle = `[WIP] Symphony: ${issueTitle}`;
9921216
const prBody = `## Symphony Contribution\n\nCloses #${issueNumber}\n\n*Work in progress via Maestro Symphony*`;
9931217
const prResult = await execFileNoThrow(
9941218
'gh',
995-
['pr', 'create', '--draft', '--title', prTitle, '--body', prBody],
1219+
['pr', 'create', '--draft', '--base', baseBranch, '--title', prTitle, '--body', prBody],
9961220
localPath
9971221
);
9981222
if (prResult.exitCode !== 0) {
999-
return { success: false, error: 'Failed to create draft PR' };
1223+
// Attempt to clean up the remote branch
1224+
logger.warn('PR creation failed, cleaning up remote branch', LOG_CONTEXT, { branchName });
1225+
await execFileNoThrow('git', ['push', 'origin', '--delete', branchName], localPath);
1226+
logger.error('Failed to create draft PR', LOG_CONTEXT, { localPath, error: prResult.stderr });
1227+
return { success: false, error: `Failed to create draft PR: ${prResult.stderr}` };
10001228
}
10011229

10021230
const prUrl = prResult.stdout.trim();
10031231
const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
10041232
const prNumber = prNumberMatch ? parseInt(prNumberMatch[1], 10) : 0;
10051233

1006-
// 5. Copy Auto Run documents to local folder
1234+
// 6. Copy Auto Run documents to local folder
10071235
const autoRunDir = path.join(localPath, 'Auto Run Docs');
10081236
await fs.mkdir(autoRunDir, { recursive: true });
10091237

10101238
for (const docPath of documentPaths) {
1011-
const sourcePath = path.join(localPath, docPath);
1239+
// Ensure the path doesn't escape the localPath
1240+
const resolvedSource = path.resolve(localPath, docPath);
1241+
if (!resolvedSource.startsWith(localPath)) {
1242+
logger.error('Attempted path traversal in document copy', LOG_CONTEXT, { docPath });
1243+
continue;
1244+
}
10121245
const destPath = path.join(autoRunDir, path.basename(docPath));
10131246
try {
1014-
await fs.copyFile(sourcePath, destPath);
1247+
await fs.copyFile(resolvedSource, destPath);
1248+
logger.info('Copied document', LOG_CONTEXT, { from: resolvedSource, to: destPath });
10151249
} catch (e) {
1016-
logger.warn('Failed to copy document', LOG_CONTEXT, { docPath, error: e });
1250+
logger.warn('Failed to copy document', LOG_CONTEXT, { docPath, error: e instanceof Error ? e.message : String(e) });
10171251
}
10181252
}
10191253

1020-
// 6. Broadcast status update
1254+
// 7. Broadcast status update
10211255
const mainWindow = getMainWindow?.();
10221256
if (mainWindow && !mainWindow.isDestroyed()) {
10231257
mainWindow.webContents.send('symphony:contributionStarted', {

src/shared/symphony-constants.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,18 @@ export const SYMPHONY_CATEGORIES: Record<string, { label: string; emoji: string
6666
};
6767

6868
// Document path regex patterns (to extract from issue body)
69+
// Note: These patterns are designed to prevent ReDoS attacks by:
70+
// 1. Using bounded repetition where possible
71+
// 2. Avoiding nested quantifiers
72+
// 3. Limiting whitespace matching
6973
export const DOCUMENT_PATH_PATTERNS = [
7074
// Markdown list items: - `path/to/doc.md` or - path/to/doc.md
71-
/^[\s]*[-*]\s*`?([^\s`]+\.md)`?\s*$/gm,
75+
// Limited leading whitespace to 20 chars to prevent ReDoS
76+
/^[ \t]{0,20}[-*][ \t]{1,4}`?([^\s`]+\.md)`?[ \t]*$/gm,
7277
// Numbered list: 1. `path/to/doc.md`
73-
/^[\s]*\d+\.\s*`?([^\s`]+\.md)`?\s*$/gm,
78+
/^[ \t]{0,20}\d{1,4}\.[ \t]{1,4}`?([^\s`]+\.md)`?[ \t]*$/gm,
7479
// Bare paths on their own line
75-
/^[\s]*([a-zA-Z0-9_\-./]+\.md)\s*$/gm,
80+
/^[ \t]{0,20}([a-zA-Z0-9_\-./]{1,200}\.md)[ \t]*$/gm,
7681
];
7782

7883
// Default stats for new users

0 commit comments

Comments
 (0)