Merge branch 'develop' into work/m4-llm
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed

This commit is contained in:
2026-02-04 02:28:50 +00:00
16 changed files with 1151 additions and 1204 deletions

View File

@@ -142,4 +142,69 @@ export class FederationAuditService {
securityEvent: true,
});
}
/**
* Log incoming connection attempt
* Logged for all incoming connection requests (security monitoring)
*/
logIncomingConnectionAttempt(data: {
workspaceId: string;
remoteInstanceId: string;
remoteUrl: string;
timestamp: number;
}): void {
this.logger.log({
event: "FEDERATION_INCOMING_CONNECTION_ATTEMPT",
workspaceId: data.workspaceId,
remoteInstanceId: data.remoteInstanceId,
remoteUrl: data.remoteUrl,
requestTimestamp: new Date(data.timestamp).toISOString(),
timestamp: new Date().toISOString(),
securityEvent: true,
});
}
/**
* Log incoming connection created
* Logged when an incoming connection is successfully created
*/
logIncomingConnectionCreated(data: {
workspaceId: string;
connectionId: string;
remoteInstanceId: string;
remoteUrl: string;
}): void {
this.logger.log({
event: "FEDERATION_INCOMING_CONNECTION_CREATED",
workspaceId: data.workspaceId,
connectionId: data.connectionId,
remoteInstanceId: data.remoteInstanceId,
remoteUrl: data.remoteUrl,
timestamp: new Date().toISOString(),
securityEvent: true,
});
}
/**
* Log incoming connection rejected
* Logged when an incoming connection is rejected (security event)
*/
logIncomingConnectionRejected(data: {
workspaceId: string;
remoteInstanceId: string;
remoteUrl?: string;
reason: string;
error?: string;
}): void {
this.logger.warn({
event: "FEDERATION_INCOMING_CONNECTION_REJECTED",
workspaceId: data.workspaceId,
remoteInstanceId: data.remoteInstanceId,
remoteUrl: data.remoteUrl,
reason: data.reason,
error: data.error,
timestamp: new Date().toISOString(),
securityEvent: true,
});
}
}

View File

@@ -10,6 +10,7 @@ import { HttpService } from "@nestjs/axios";
import { ConnectionService } from "./connection.service";
import { FederationService } from "./federation.service";
import { SignatureService } from "./signature.service";
import { FederationAuditService } from "./audit.service";
import { PrismaService } from "../prisma/prisma.service";
import { FederationConnectionStatus } from "@prisma/client";
import { FederationConnection } from "@prisma/client";
@@ -22,6 +23,7 @@ describe("ConnectionService", () => {
let federationService: FederationService;
let signatureService: SignatureService;
let httpService: HttpService;
let auditService: FederationAuditService;
const mockWorkspaceId = "workspace-123";
const mockRemoteUrl = "https://remote.example.com";
@@ -85,6 +87,7 @@ describe("ConnectionService", () => {
findUnique: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
},
@@ -109,6 +112,14 @@ describe("ConnectionService", () => {
post: vi.fn(),
},
},
{
provide: FederationAuditService,
useValue: {
logIncomingConnectionAttempt: vi.fn(),
logIncomingConnectionCreated: vi.fn(),
logIncomingConnectionRejected: vi.fn(),
},
},
],
}).compile();
@@ -117,6 +128,7 @@ describe("ConnectionService", () => {
federationService = module.get<FederationService>(FederationService);
signatureService = module.get<SignatureService>(SignatureService);
httpService = module.get<HttpService>(HttpService);
auditService = module.get<FederationAuditService>(FederationAuditService);
});
it("should be defined", () => {
@@ -208,6 +220,36 @@ describe("ConnectionService", () => {
})
);
});
it("should delete connection and throw error if request fails", async () => {
const mockAxiosResponse: AxiosResponse = {
data: mockRemoteIdentity,
status: 200,
statusText: "OK",
headers: {},
config: {} as never,
};
vi.spyOn(httpService, "get").mockReturnValue(of(mockAxiosResponse));
vi.spyOn(httpService, "post").mockReturnValue(
throwError(() => new Error("Connection refused"))
);
const createSpy = vi
.spyOn(prismaService.federationConnection, "create")
.mockResolvedValue(mockConnection);
const deleteSpy = vi
.spyOn(prismaService.federationConnection, "delete")
.mockResolvedValue(mockConnection);
await expect(service.initiateConnection(mockWorkspaceId, mockRemoteUrl)).rejects.toThrow(
"Failed to initiate connection"
);
expect(createSpy).toHaveBeenCalled();
expect(deleteSpy).toHaveBeenCalledWith({
where: { id: mockConnection.id },
});
});
});
describe("acceptConnection", () => {
@@ -418,5 +460,55 @@ describe("ConnectionService", () => {
service.handleIncomingConnectionRequest(mockWorkspaceId, mockRequest)
).rejects.toThrow("Invalid connection request signature");
});
it("should log incoming connection attempt", async () => {
vi.spyOn(signatureService, "verifyConnectionRequest").mockReturnValue({ valid: true });
vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection);
const auditSpy = vi.spyOn(auditService, "logIncomingConnectionAttempt");
await service.handleIncomingConnectionRequest(mockWorkspaceId, mockRequest);
expect(auditSpy).toHaveBeenCalledWith({
workspaceId: mockWorkspaceId,
remoteInstanceId: mockRequest.instanceId,
remoteUrl: mockRequest.instanceUrl,
timestamp: mockRequest.timestamp,
});
});
it("should log connection created on success", async () => {
vi.spyOn(signatureService, "verifyConnectionRequest").mockReturnValue({ valid: true });
vi.spyOn(prismaService.federationConnection, "create").mockResolvedValue(mockConnection);
const auditSpy = vi.spyOn(auditService, "logIncomingConnectionCreated");
await service.handleIncomingConnectionRequest(mockWorkspaceId, mockRequest);
expect(auditSpy).toHaveBeenCalledWith({
workspaceId: mockWorkspaceId,
connectionId: mockConnection.id,
remoteInstanceId: mockRequest.instanceId,
remoteUrl: mockRequest.instanceUrl,
});
});
it("should log connection rejected on invalid signature", async () => {
vi.spyOn(signatureService, "verifyConnectionRequest").mockReturnValue({
valid: false,
error: "Invalid signature",
});
const auditSpy = vi.spyOn(auditService, "logIncomingConnectionRejected");
await expect(
service.handleIncomingConnectionRequest(mockWorkspaceId, mockRequest)
).rejects.toThrow();
expect(auditSpy).toHaveBeenCalledWith({
workspaceId: mockWorkspaceId,
remoteInstanceId: mockRequest.instanceId,
remoteUrl: mockRequest.instanceUrl,
reason: "Invalid signature",
error: "Invalid signature",
});
});
});
});

View File

@@ -10,12 +10,14 @@ import {
NotFoundException,
UnauthorizedException,
ServiceUnavailableException,
BadRequestException,
} from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { FederationConnectionStatus, Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { FederationService } from "./federation.service";
import { SignatureService } from "./signature.service";
import { FederationAuditService } from "./audit.service";
import { firstValueFrom } from "rxjs";
import type { ConnectionRequest, ConnectionDetails } from "./types/connection.types";
import type { PublicInstanceIdentity } from "./types/instance.types";
@@ -28,7 +30,8 @@ export class ConnectionService {
private readonly prisma: PrismaService,
private readonly federationService: FederationService,
private readonly signatureService: SignatureService,
private readonly httpService: HttpService
private readonly httpService: HttpService,
private readonly auditService: FederationAuditService
) {}
/**
@@ -68,7 +71,7 @@ export class ConnectionService {
const signature = await this.signatureService.signMessage(request);
const signedRequest: ConnectionRequest = { ...request, signature };
// Send connection request to remote instance (fire-and-forget for now)
// Send connection request to remote instance
try {
await firstValueFrom(
this.httpService.post(`${remoteUrl}/api/v1/federation/incoming/connect`, signedRequest)
@@ -76,7 +79,16 @@ export class ConnectionService {
this.logger.log(`Connection request sent to ${remoteUrl}`);
} catch (error) {
this.logger.error(`Failed to send connection request to ${remoteUrl}`, error);
// Connection is still created in PENDING state, can be retried
// Delete the failed connection to prevent zombie connections in PENDING state
await this.prisma.federationConnection.delete({
where: { id: connection.id },
});
const errorMessage = error instanceof Error ? error.message : "Unknown error";
throw new BadRequestException(
`Failed to initiate connection to ${remoteUrl}: ${errorMessage}`
);
}
return this.mapToConnectionDetails(connection);
@@ -265,12 +277,30 @@ export class ConnectionService {
): Promise<ConnectionDetails> {
this.logger.log(`Received connection request from ${request.instanceId}`);
// Audit log: Incoming connection attempt
this.auditService.logIncomingConnectionAttempt({
workspaceId,
remoteInstanceId: request.instanceId,
remoteUrl: request.instanceUrl,
timestamp: request.timestamp,
});
// Verify signature
const validation = this.signatureService.verifyConnectionRequest(request);
if (!validation.valid) {
const errorMsg: string = validation.error ?? "Unknown error";
this.logger.warn(`Invalid connection request from ${request.instanceId}: ${errorMsg}`);
// Audit log: Connection rejected
this.auditService.logIncomingConnectionRejected({
workspaceId,
remoteInstanceId: request.instanceId,
remoteUrl: request.instanceUrl,
reason: "Invalid signature",
error: errorMsg,
});
throw new UnauthorizedException("Invalid connection request signature");
}
@@ -291,6 +321,14 @@ export class ConnectionService {
this.logger.log(`Created pending connection ${connection.id} from ${request.instanceId}`);
// Audit log: Connection created
this.auditService.logIncomingConnectionCreated({
workspaceId,
connectionId: connection.id,
remoteInstanceId: request.instanceId,
remoteUrl: request.instanceUrl,
});
return this.mapToConnectionDetails(connection);
}

View File

@@ -7,10 +7,65 @@ import {
IsOptional,
ArrayNotEmpty,
IsIn,
Validate,
ValidatorConstraint,
ValidatorConstraintInterface,
ValidationArguments,
} from "class-validator";
import { Type } from "class-transformer";
import { AgentType } from "../../../spawner/types/agent-spawner.types";
import { GateProfileType } from "../../../coordinator/types/gate-config.types";
import { validateBranchName, validateRepositoryUrl } from "../../../git/git-validation.util";
/**
* Custom validator for git branch names
* Uses whitelist-based validation to prevent command injection
*/
@ValidatorConstraint({ name: "isValidBranchName", async: false })
export class IsValidBranchName implements ValidatorConstraintInterface {
validate(branchName: string, _args: ValidationArguments): boolean {
try {
validateBranchName(branchName);
return true;
} catch {
return false;
}
}
defaultMessage(args: ValidationArguments): string {
try {
validateBranchName(args.value as string);
return "Branch name is invalid";
} catch (error) {
return error instanceof Error ? error.message : "Branch name is invalid";
}
}
}
/**
* Custom validator for git repository URLs
* Prevents SSRF and command injection via dangerous protocols
*/
@ValidatorConstraint({ name: "isValidRepositoryUrl", async: false })
export class IsValidRepositoryUrl implements ValidatorConstraintInterface {
validate(repositoryUrl: string, _args: ValidationArguments): boolean {
try {
validateRepositoryUrl(repositoryUrl);
return true;
} catch {
return false;
}
}
defaultMessage(args: ValidationArguments): string {
try {
validateRepositoryUrl(args.value as string);
return "Repository URL is invalid";
} catch (error) {
return error instanceof Error ? error.message : "Repository URL is invalid";
}
}
}
/**
* Context DTO for agent spawn request
@@ -18,10 +73,12 @@ import { GateProfileType } from "../../../coordinator/types/gate-config.types";
export class AgentContextDto {
@IsString()
@IsNotEmpty()
@Validate(IsValidRepositoryUrl)
repository!: string;
@IsString()
@IsNotEmpty()
@Validate(IsValidBranchName)
branch!: string;
@IsArray()

View File

@@ -0,0 +1,238 @@
/**
* Git Validation Utility Tests
*
* Tests for command injection prevention in git operations
*/
import { describe, it, expect } from "vitest";
import { BadRequestException } from "@nestjs/common";
import {
validateBranchName,
validateRepositoryUrl,
validateSpawnContext,
} from "./git-validation.util";
describe("validateBranchName", () => {
describe("Valid branch names", () => {
it("should accept standard branch names", () => {
expect(() => validateBranchName("main")).not.toThrow();
expect(() => validateBranchName("develop")).not.toThrow();
expect(() => validateBranchName("master")).not.toThrow();
});
it("should accept feature branch names with slashes", () => {
expect(() => validateBranchName("feature/add-login")).not.toThrow();
expect(() => validateBranchName("fix/bug-123")).not.toThrow();
expect(() => validateBranchName("hotfix/security-patch")).not.toThrow();
});
it("should accept branch names with hyphens and underscores", () => {
expect(() => validateBranchName("feature-branch")).not.toThrow();
expect(() => validateBranchName("feature_branch")).not.toThrow();
expect(() => validateBranchName("feature-branch_v2")).not.toThrow();
});
it("should accept branch names with dots", () => {
expect(() => validateBranchName("release/1.0.0")).not.toThrow();
expect(() => validateBranchName("v2.5.1")).not.toThrow();
});
it("should accept branch names with numbers", () => {
expect(() => validateBranchName("feature-123")).not.toThrow();
expect(() => validateBranchName("123-bugfix")).not.toThrow();
});
});
describe("Invalid branch names (Command Injection)", () => {
it("should reject empty or whitespace-only names", () => {
expect(() => validateBranchName("")).toThrow(BadRequestException);
expect(() => validateBranchName(" ")).toThrow(BadRequestException);
expect(() => validateBranchName("\t")).toThrow(BadRequestException);
});
it("should reject names starting with hyphen (option injection)", () => {
expect(() => validateBranchName("--config")).toThrow(BadRequestException);
expect(() => validateBranchName("-malicious")).toThrow(BadRequestException);
});
it("should reject names with double dots (range specification)", () => {
expect(() => validateBranchName("feature..main")).toThrow(BadRequestException);
expect(() => validateBranchName("..malicious")).toThrow(BadRequestException);
});
it("should reject names with path traversal patterns", () => {
expect(() => validateBranchName("../etc/passwd")).toThrow(BadRequestException);
expect(() => validateBranchName("feature/../main")).toThrow(BadRequestException);
expect(() => validateBranchName("malicious/..")).toThrow(BadRequestException);
});
it("should reject names ending with .lock (reserved by git)", () => {
expect(() => validateBranchName("feature.lock")).toThrow(BadRequestException);
expect(() => validateBranchName("main.lock")).toThrow(BadRequestException);
});
it("should reject names with special shell characters", () => {
expect(() => validateBranchName("feature;rm -rf /")).toThrow(BadRequestException);
expect(() => validateBranchName("feature$malicious")).toThrow(BadRequestException);
expect(() => validateBranchName("feature`whoami`")).toThrow(BadRequestException);
expect(() => validateBranchName("feature$(whoami)")).toThrow(BadRequestException);
expect(() => validateBranchName("feature|malicious")).toThrow(BadRequestException);
expect(() => validateBranchName("feature&malicious")).toThrow(BadRequestException);
});
it("should reject names with control characters", () => {
expect(() => validateBranchName("feature\x00malicious")).toThrow(BadRequestException);
expect(() => validateBranchName("feature\x1Fmalicious")).toThrow(BadRequestException);
expect(() => validateBranchName("feature\x7Fmalicious")).toThrow(BadRequestException);
});
it("should reject names exceeding maximum length", () => {
const longName = "a".repeat(256);
expect(() => validateBranchName(longName)).toThrow(BadRequestException);
});
it("should reject names with spaces", () => {
expect(() => validateBranchName("feature branch")).toThrow(BadRequestException);
expect(() => validateBranchName("feature branch")).toThrow(BadRequestException);
});
});
});
describe("validateRepositoryUrl", () => {
describe("Valid repository URLs", () => {
it("should accept HTTPS URLs", () => {
expect(() => validateRepositoryUrl("https://github.com/user/repo.git")).not.toThrow();
expect(() => validateRepositoryUrl("https://gitlab.com/group/project.git")).not.toThrow();
expect(() => validateRepositoryUrl("https://bitbucket.org/user/repo.git")).not.toThrow();
});
it("should accept HTTP URLs (for development)", () => {
expect(() => validateRepositoryUrl("http://git.example.com/repo.git")).not.toThrow();
});
it("should accept SSH URLs with git@ format", () => {
expect(() => validateRepositoryUrl("git@github.com:user/repo.git")).not.toThrow();
expect(() => validateRepositoryUrl("ssh://git@gitlab.com/user/repo.git")).not.toThrow();
});
it("should accept git:// protocol", () => {
expect(() => validateRepositoryUrl("git://github.com/user/repo.git")).not.toThrow();
});
});
describe("Invalid repository URLs (Security Risks)", () => {
it("should reject empty or whitespace-only URLs", () => {
expect(() => validateRepositoryUrl("")).toThrow(BadRequestException);
expect(() => validateRepositoryUrl(" ")).toThrow(BadRequestException);
});
it("should reject dangerous protocols (file://)", () => {
expect(() => validateRepositoryUrl("file:///etc/passwd")).toThrow(BadRequestException);
expect(() => validateRepositoryUrl("file://C:/Windows/System32")).toThrow(
BadRequestException
);
});
it("should reject dangerous protocols (javascript:, data:)", () => {
expect(() => validateRepositoryUrl("javascript:alert('XSS')")).toThrow(BadRequestException);
expect(() => validateRepositoryUrl("data:text/html,<script>alert('XSS')</script>")).toThrow(
BadRequestException
);
});
it("should reject localhost URLs (SSRF protection)", () => {
expect(() => validateRepositoryUrl("https://localhost/repo.git")).toThrow(
BadRequestException
);
expect(() => validateRepositoryUrl("https://127.0.0.1/repo.git")).toThrow(
BadRequestException
);
expect(() => validateRepositoryUrl("https://0.0.0.0/repo.git")).toThrow(BadRequestException);
expect(() => validateRepositoryUrl("http://::1/repo.git")).toThrow(BadRequestException);
});
it("should reject internal network URLs (SSRF protection)", () => {
expect(() => validateRepositoryUrl("https://192.168.1.1/repo.git")).toThrow(
BadRequestException
);
expect(() => validateRepositoryUrl("https://10.0.0.1/repo.git")).toThrow(BadRequestException);
expect(() => validateRepositoryUrl("https://172.16.0.1/repo.git")).toThrow(
BadRequestException
);
});
it("should reject URLs with embedded credentials", () => {
expect(() => validateRepositoryUrl("https://user:pass@github.com/repo.git")).toThrow(
BadRequestException
);
});
it("should reject URLs with shell special characters", () => {
expect(() => validateRepositoryUrl("https://github.com/repo.git;whoami")).toThrow(
BadRequestException
);
expect(() => validateRepositoryUrl("https://github.com/repo.git|malicious")).toThrow(
BadRequestException
);
expect(() => validateRepositoryUrl("https://github.com/repo.git&malicious")).toThrow(
BadRequestException
);
expect(() => validateRepositoryUrl("https://github.com/repo.git$malicious")).toThrow(
BadRequestException
);
expect(() => validateRepositoryUrl("https://github.com/repo.git`whoami`")).toThrow(
BadRequestException
);
});
it("should reject URLs exceeding maximum length", () => {
const longUrl = "https://github.com/" + "a".repeat(2000) + ".git";
expect(() => validateRepositoryUrl(longUrl)).toThrow(BadRequestException);
});
it("should reject unknown/dangerous protocols", () => {
expect(() => validateRepositoryUrl("ftp://example.com/repo.git")).toThrow(
BadRequestException
);
expect(() => validateRepositoryUrl("telnet://example.com")).toThrow(BadRequestException);
});
});
});
describe("validateSpawnContext", () => {
it("should validate both repository and branch", () => {
expect(() =>
validateSpawnContext({
repository: "https://github.com/user/repo.git",
branch: "feature/add-login",
})
).not.toThrow();
});
it("should reject invalid repository", () => {
expect(() =>
validateSpawnContext({
repository: "file:///etc/passwd",
branch: "main",
})
).toThrow(BadRequestException);
});
it("should reject invalid branch", () => {
expect(() =>
validateSpawnContext({
repository: "https://github.com/user/repo.git",
branch: "--config malicious",
})
).toThrow(BadRequestException);
});
it("should reject both invalid repository and branch", () => {
expect(() =>
validateSpawnContext({
repository: "javascript:alert('XSS')",
branch: "$(whoami)",
})
).toThrow(BadRequestException);
});
});

View File

@@ -0,0 +1,219 @@
/**
* 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, Logger } from "@nestjs/common";
const logger = new Logger("GitValidation");
/**
* 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)) {
logger.warn({
event: "GIT_COMMAND_INJECTION_BLOCKED",
input: branchName,
reason: "Invalid characters detected",
securityEvent: true,
timestamp: new Date().toISOString(),
});
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("-")) {
logger.warn({
event: "GIT_OPTION_INJECTION_BLOCKED",
input: branchName,
reason: "Branch name starts with hyphen (option injection attempt)",
securityEvent: true,
timestamp: new Date().toISOString(),
});
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("..")) {
logger.warn({
event: "GIT_RANGE_INJECTION_BLOCKED",
input: branchName,
reason: "Double dots detected (git range specification)",
securityEvent: true,
timestamp: new Date().toISOString(),
});
throw new BadRequestException("Branch name cannot contain consecutive dots (..)");
}
// Prevent path traversal patterns
if (branchName.includes("/../") || branchName.startsWith("../") || branchName.endsWith("/..")) {
logger.warn({
event: "GIT_PATH_TRAVERSAL_BLOCKED",
input: branchName,
reason: "Path traversal pattern detected",
securityEvent: true,
timestamp: new Date().toISOString(),
});
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)) {
logger.warn({
event: "GIT_DANGEROUS_PROTOCOL_BLOCKED",
input: url,
protocol: dangerous,
reason: `Dangerous protocol detected: ${dangerous}`,
securityEvent: true,
timestamp: new Date().toISOString(),
});
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)) {
logger.warn({
event: "GIT_SSRF_ATTEMPT_BLOCKED",
input: url,
reason: "Repository URL points to localhost or internal network",
securityEvent: true,
timestamp: new Date().toISOString(),
});
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);
}

View File

@@ -3,6 +3,7 @@ import { simpleGit, SimpleGit } from "simple-git";
import * as path from "path";
import { GitOperationsService } from "./git-operations.service";
import { WorktreeInfo, WorktreeError } from "./types";
import { validateBranchName } from "./git-validation.util";
/**
* Result of worktree cleanup operation
@@ -70,6 +71,10 @@ export class WorktreeManagerService {
throw new Error("taskId is required");
}
// Validate baseBranch to prevent command injection
// This is defense-in-depth - DTO validation should catch this first
validateBranchName(baseBranch);
const worktreePath = this.getWorktreePath(repoPath, agentId, taskId);
const branchName = this.getBranchName(agentId, taskId);
@@ -79,6 +84,7 @@ export class WorktreeManagerService {
const git = this.getGit(repoPath);
// Create worktree with new branch
// baseBranch is validated above to prevent command injection
await git.raw(["worktree", "add", worktreePath, "-b", branchName, baseBranch]);
this.logger.log(`Successfully created worktree at ${worktreePath}`);