fix(#274): Add input validation to prevent command injection in git operations
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

Implemented strict whitelist-based validation for git branch names and
repository URLs to prevent command injection vulnerabilities in worktree
operations.

Security fixes:
- Created git-validation.util.ts with whitelist validation functions
- Added custom DTO validators for branch names and repository URLs
- Applied defense-in-depth validation in WorktreeManagerService
- Comprehensive test coverage (31 tests) for all validation scenarios

Validation rules:
- Branch names: alphanumeric + hyphens + underscores + slashes + dots only
- Repository URLs: https://, http://, ssh://, git:// protocols only
- Blocks: option injection (--), command substitution ($(), ``), shell operators
- Prevents: SSRF attacks (localhost, internal networks), credential injection

Defense layers:
1. DTO validation (first line of defense at API boundary)
2. Service-level validation (defense-in-depth before git operations)

Fixes #274

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 20:17:47 -06:00
parent 148121c9d4
commit 7a84d96d72
5 changed files with 555 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
/**
* Git Input Validation Utility
*
* Provides strict validation for git references (branch names, repository URLs)
* to prevent command injection vulnerabilities.
*
* Security: Whitelist-based approach - only allow known-safe characters.
*/
import { BadRequestException } from "@nestjs/common";
/**
* Validates a git branch name for safety
*
* Allowed format: alphanumeric, hyphens, underscores, forward slashes
* Examples: "main", "feature/add-login", "fix/bug_123"
*
* Rejected: Special characters that could be interpreted as git syntax
* Examples: "--option", "$(command)", ";malicious", "`command`"
*
* @param branchName - The branch name to validate
* @throws BadRequestException if branch name is invalid
*/
export function validateBranchName(branchName: string): void {
// Check for empty or whitespace-only
if (!branchName || branchName.trim().length === 0) {
throw new BadRequestException("Branch name cannot be empty");
}
// Check length (git has a 255 char limit for ref names)
if (branchName.length > 255) {
throw new BadRequestException("Branch name exceeds maximum length (255 characters)");
}
// Whitelist: only allow alphanumeric, hyphens, underscores, forward slashes, dots
// This prevents all forms of command injection
const safePattern = /^[a-zA-Z0-9/_.-]+$/;
if (!safePattern.test(branchName)) {
throw new BadRequestException(
`Branch name contains invalid characters. Only alphanumeric, hyphens, underscores, slashes, and dots are allowed.`
);
}
// Prevent git option injection (branch names starting with -)
if (branchName.startsWith("-")) {
throw new BadRequestException(
"Branch name cannot start with a hyphen (prevents option injection)"
);
}
// Prevent double dots (used for range specifications in git)
if (branchName.includes("..")) {
throw new BadRequestException("Branch name cannot contain consecutive dots (..)");
}
// Prevent path traversal patterns
if (branchName.includes("/../") || branchName.startsWith("../") || branchName.endsWith("/..")) {
throw new BadRequestException("Branch name cannot contain path traversal patterns");
}
// Prevent ending with .lock (reserved by git)
if (branchName.endsWith(".lock")) {
throw new BadRequestException("Branch name cannot end with .lock (reserved by git)");
}
// Prevent control characters
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1F\x7F]/.test(branchName)) {
throw new BadRequestException("Branch name cannot contain control characters");
}
}
/**
* Validates a git repository URL for safety
*
* Allowed protocols: https, http (dev only), ssh (git@)
* Prevents: file://, javascript:, data:, and other dangerous protocols
*
* @param repositoryUrl - The repository URL to validate
* @throws BadRequestException if URL is invalid or uses dangerous protocol
*/
export function validateRepositoryUrl(repositoryUrl: string): void {
// Check for empty or whitespace-only
if (!repositoryUrl || repositoryUrl.trim().length === 0) {
throw new BadRequestException("Repository URL cannot be empty");
}
// Check length (reasonable limit for URLs)
if (repositoryUrl.length > 2000) {
throw new BadRequestException("Repository URL exceeds maximum length (2000 characters)");
}
// Remove whitespace
const url = repositoryUrl.trim();
// Whitelist allowed protocols
const httpsPattern = /^https:\/\//i;
const httpPattern = /^http:\/\//i; // Only for development
const sshGitPattern = /^git@[a-zA-Z0-9.-]+:/; // git@host:repo format
const sshUrlPattern = /^ssh:\/\/git@[a-zA-Z0-9.-]+(\/|:)/; // ssh://git@host/repo or ssh://git@host:repo
if (
!httpsPattern.test(url) &&
!httpPattern.test(url) &&
!sshGitPattern.test(url) &&
!sshUrlPattern.test(url) &&
!url.startsWith("git://")
) {
throw new BadRequestException(
"Repository URL must use https://, http://, ssh://, git://, or git@ protocol"
);
}
// Prevent dangerous protocols
const dangerousProtocols = [
"file://",
"javascript:",
"data:",
"vbscript:",
"about:",
"chrome:",
"view-source:",
];
for (const dangerous of dangerousProtocols) {
if (url.toLowerCase().startsWith(dangerous)) {
throw new BadRequestException(
`Repository URL cannot use ${dangerous} protocol (security risk)`
);
}
}
// Prevent localhost/internal network access (SSRF protection)
const localhostPatterns = [
/https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|::1)/i,
/https?:\/\/192\.168\./i,
/https?:\/\/10\./i,
/https?:\/\/172\.(1[6-9]|2\d|3[01])\./i,
];
for (const pattern of localhostPatterns) {
if (pattern.test(url)) {
throw new BadRequestException(
"Repository URL cannot point to localhost or internal networks (SSRF protection)"
);
}
}
// Prevent credential injection in URL
if (url.includes("@") && !sshGitPattern.test(url) && !sshUrlPattern.test(url)) {
// Extract the part before @ to check if it looks like credentials
const beforeAt = url.split("@")[0];
if (beforeAt.includes("://") && beforeAt.split("://")[1].includes(":")) {
throw new BadRequestException("Repository URL cannot contain embedded credentials");
}
}
// Prevent control characters and dangerous characters in URL
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1F\x7F`$;|&]/.test(url)) {
throw new BadRequestException("Repository URL contains invalid or dangerous characters");
}
}
/**
* Validates a complete agent spawn context
*
* @param context - The spawn context with repository and branch
* @throws BadRequestException if any field is invalid
*/
export function validateSpawnContext(context: { repository: string; branch: string }): void {
validateRepositoryUrl(context.repository);
validateBranchName(context.branch);
}