fix(server): sanitize repo name to prevent argument injection

This commit is contained in:
RinZ27 2026-04-26 21:21:08 +07:00
parent 98ee665889
commit 0895ffd17d
No known key found for this signature in database
4 changed files with 47 additions and 6 deletions

View file

@ -14,8 +14,24 @@ import { isIP } from 'net';
/** Extract the repository name from a git URL (HTTPS or SSH). */
export function extractRepoName(url: string): string {
const cleaned = url.replace(/\/+$/, '');
const lastSegment = cleaned.split(/[/:]/).pop() || 'unknown';
return lastSegment.replace(/\.git$/, '');
// For SSH URLs like git@github.com:user/repo.git, we need to handle the colon.
// For HTTPS URLs, the only colon should be in the protocol.
let lastSegment: string;
if (cleaned.includes('@') && cleaned.includes(':') && !cleaned.startsWith('http')) {
// Likely an SSH URL
lastSegment = cleaned.split(/[:/]/).pop() || 'unknown';
} else {
// Likely an HTTPS URL or local path
lastSegment = cleaned.split('/').pop() || 'unknown';
}
const name = lastSegment.replace(/\.git$/, '');
// Sanitize the name:
// 1. Prevent argument injection by stripping leading dashes.
// 2. Remove characters that are unsafe for directory names across platforms.
return name.replace(/^-+/, '').replace(/[<>:"/\\|?*]/g, '_') || 'unknown';
}
/** Get the clone target directory for a repo name. */

View file

@ -151,9 +151,19 @@ export const parseRepoNameFromUrl = (url: string | null | undefined): string | n
if (!trimmed) return null;
// Strip `.git` suffix (case-insensitive) and any trailing slashes.
const withoutSuffix = trimmed.replace(/\.git\/*$/i, '').replace(/\/+$/, '');
// Last path segment, splitting on either `/` or `:` (covers SSH form).
const m = withoutSuffix.match(/[/:]([^/:]+)$/);
const candidate = m ? m[1] : withoutSuffix;
// Last path segment, handling colons for SSH URLs.
// For HTTPS URLs, the only colon should be in the protocol.
let candidate: string;
if (withoutSuffix.includes('@') && withoutSuffix.includes(':') && !withoutSuffix.startsWith('http')) {
// Likely an SSH URL
const m = withoutSuffix.match(/[/:]([^/:]+)$/);
candidate = m ? m[1] : withoutSuffix;
} else {
// Likely an HTTPS URL or local path
candidate = withoutSuffix.split('/').pop() || withoutSuffix;
}
return candidate || null;
};

View file

@ -22,6 +22,21 @@ describe('git-clone', () => {
it('handles nested paths', () => {
expect(extractRepoName('https://gitlab.com/group/subgroup/repo.git')).toBe('repo');
});
it('strips leading dashes to prevent argument injection', () => {
expect(extractRepoName('https://github.com/user/--upload-pack=payload.git')).toBe('upload-pack=payload');
expect(extractRepoName('https://github.com/user/-repo')).toBe('repo');
});
it('sanitizes unsafe directory characters', () => {
expect(extractRepoName('https://github.com/user/repo<tag>.git')).toBe('repo_tag_');
expect(extractRepoName('https://github.com/user/repo:name')).toBe('repo_name');
});
it('handles colons in SSH URLs correctly', () => {
expect(extractRepoName('git@github.com:user/my-repo.git')).toBe('my-repo');
expect(extractRepoName('git@github.com:my-repo.git')).toBe('my-repo');
});
});
describe('getCloneDir', () => {

View file

@ -14,5 +14,5 @@
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*"]
"include": ["src/**/*", "test/**/*"]
}