diff --git a/packages/@n8n/api-types/src/dto/git-connections/__tests__/git-connections.dto.test.ts b/packages/@n8n/api-types/src/dto/git-connections/__tests__/git-connections.dto.test.ts new file mode 100644 index 00000000000..2adc133dd08 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/git-connections/__tests__/git-connections.dto.test.ts @@ -0,0 +1,57 @@ +import { CreateGitConnectionDto, UpdateGitConnectionDto } from '../git-connections.dto'; + +describe('Git connection DTOs', () => { + it('accepts SSH and HTTPS create payloads', () => { + expect( + CreateGitConnectionDto.safeParse({ + name: 'Deployments', + repositoryUrl: 'git@example.com:org/repo.git', + connectionType: 'ssh', + keyGeneratorType: 'ed25519', + }), + ).toMatchObject({ success: true }); + expect( + CreateGitConnectionDto.safeParse({ + name: 'Deployments', + repositoryUrl: 'https://example.com/org/repo.git', + connectionType: 'https', + username: 'git-user', + password: 'secret', + }), + ).toMatchObject({ success: true }); + }); + + it('rejects empty names, branch names, and credentials', () => { + const base = { + name: 'Deployments', + repositoryUrl: 'https://example.com/org/repo.git', + connectionType: 'https' as const, + username: 'git-user', + password: 'secret', + }; + expect(CreateGitConnectionDto.safeParse({ ...base, name: ' ' }).success).toBe(false); + expect(CreateGitConnectionDto.safeParse({ ...base, branchName: '' }).success).toBe(false); + expect(CreateGitConnectionDto.safeParse({ ...base, password: '' }).success).toBe(false); + }); + + it('caps branchName at the 255-char column width', () => { + const base = { + name: 'Deployments', + repositoryUrl: 'https://example.com/org/repo.git', + connectionType: 'https' as const, + username: 'git-user', + password: 'secret', + }; + expect(CreateGitConnectionDto.safeParse({ ...base, branchName: 'a'.repeat(255) }).success).toBe( + true, + ); + expect(CreateGitConnectionDto.safeParse({ ...base, branchName: 'a'.repeat(256) }).success).toBe( + false, + ); + }); + + it('allows partial updates and rejects null branches', () => { + expect(UpdateGitConnectionDto.safeParse({ name: 'Renamed' }).success).toBe(true); + expect(UpdateGitConnectionDto.safeParse({ branchName: null }).success).toBe(false); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/git-connections/git-connections.dto.ts b/packages/@n8n/api-types/src/dto/git-connections/git-connections.dto.ts new file mode 100644 index 00000000000..adb181e8e23 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/git-connections/git-connections.dto.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; +import { publicApiPaginationSchema } from '../pagination/pagination.dto'; + +export const gitConnectionTypeSchema = z.enum(['ssh', 'https']); +export const gitKeyGeneratorTypeSchema = z.enum(['ed25519', 'rsa']); +export type GitConnectionType = z.infer; +export type GitKeyGeneratorType = z.infer; + +const nameSchema = z.string().trim().min(1).max(128); +const repositoryUrlSchema = z.string().trim().min(1); +// branchName maps to a `varchar(255)` column — cap it so over-long input is a +// 400 at validation time rather than a 500 at insert. +const branchNameSchema = z.string().trim().min(1).max(255); + +export class CreateGitConnectionDto extends Z.class({ + name: nameSchema, + repositoryUrl: repositoryUrlSchema, + branchName: branchNameSchema.optional(), + connectionType: gitConnectionTypeSchema, + keyGeneratorType: gitKeyGeneratorTypeSchema.optional(), + username: z.string().min(1).optional(), + password: z.string().min(1).optional(), +}) {} + +export class UpdateGitConnectionDto extends Z.class({ + name: nameSchema.optional(), + repositoryUrl: repositoryUrlSchema.optional(), + branchName: branchNameSchema.optional(), + connectionType: gitConnectionTypeSchema.optional(), + keyGeneratorType: gitKeyGeneratorTypeSchema.optional(), + username: z.string().min(1).optional(), + password: z.string().min(1).optional(), +}) {} + +export class ConnectGitConnectionDto extends Z.class({ + branchName: branchNameSchema.optional(), +}) {} + +export class ListGitConnectionsQueryDto extends Z.class({ + limit: publicApiPaginationSchema.limit, + cursor: z.string().optional(), +}) {} + +export const gitConnectionPublicSchema = z.object({ + id: z.string(), + name: z.string(), + repositoryUrl: z.string(), + branchName: z.string().nullable(), + connectionType: gitConnectionTypeSchema, + publicKey: z.string().nullable(), + keyGeneratorType: gitKeyGeneratorTypeSchema.nullable(), + baseCommit: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export class GitConnectionPublicDto extends Z.class(gitConnectionPublicSchema.shape) {} + +export const gitConnectionSummarySchema = gitConnectionPublicSchema.omit({ publicKey: true }); + +export class GitConnectionListPublicDto extends Z.class({ + data: z.array(gitConnectionSummarySchema), + nextCursor: z.string().nullable(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 81b60994219..ddab9856807 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -98,6 +98,18 @@ export { } from './source-control/pull-work-folder-request.dto'; export { PushWorkFolderRequestDto } from './source-control/push-work-folder-request.dto'; export { type GitCommitInfo } from './source-control/push-work-folder-response.dto'; +export { + ConnectGitConnectionDto, + CreateGitConnectionDto, + GitConnectionListPublicDto, + GitConnectionPublicDto, + ListGitConnectionsQueryDto, + UpdateGitConnectionDto, + gitConnectionTypeSchema, + gitKeyGeneratorTypeSchema, + type GitConnectionType, + type GitKeyGeneratorType, +} from './git-connections/git-connections.dto'; export { CreateCredentialDto } from './credentials/create-credential.dto'; export { VariableListRequestDto } from './variables/variables-list-request.dto'; diff --git a/packages/@n8n/backend-common/src/modules/modules.config.ts b/packages/@n8n/backend-common/src/modules/modules.config.ts index 3d9c2179271..d4b3445d1eb 100644 --- a/packages/@n8n/backend-common/src/modules/modules.config.ts +++ b/packages/@n8n/backend-common/src/modules/modules.config.ts @@ -14,6 +14,7 @@ export const MODULE_NAMES = [ 'provisioning', 'breaking-changes', 'source-control', + 'git-connections', 'dynamic-credentials', 'chat-hub', 'sso-oidc', diff --git a/packages/@n8n/config/src/configs/logging.config.ts b/packages/@n8n/config/src/configs/logging.config.ts index e8a125a3b1f..aee06c76959 100644 --- a/packages/@n8n/config/src/configs/logging.config.ts +++ b/packages/@n8n/config/src/configs/logging.config.ts @@ -32,6 +32,7 @@ export const LOG_SCOPES = [ 'breaking-changes', 'circuit-breaker', 'source-control', + 'git-connections', 'dynamic-credentials', 'workflow-history-compaction', 'data-table-csv-import', diff --git a/packages/@n8n/constants/src/index.ts b/packages/@n8n/constants/src/index.ts index 52d93e035a2..d6292d3df7f 100644 --- a/packages/@n8n/constants/src/index.ts +++ b/packages/@n8n/constants/src/index.ts @@ -17,6 +17,7 @@ export const LICENSE_FEATURES = { ADVANCED_EXECUTION_FILTERS: 'feat:advancedExecutionFilters', VARIABLES: 'feat:variables', SOURCE_CONTROL: 'feat:sourceControl', + GIT_CONNECTIONS: 'feat:gitConnections', API_DISABLED: 'feat:apiDisabled', EXTERNAL_SECRETS: 'feat:externalSecrets', SHOW_NON_PROD_BANNER: 'feat:showNonProdBanner', diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index bde9d3b056b..320a2ddbc32 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -105,6 +105,7 @@ export class E2EController { [LICENSE_FEATURES.LOG_STREAMING]: false, [LICENSE_FEATURES.ADVANCED_EXECUTION_FILTERS]: false, [LICENSE_FEATURES.SOURCE_CONTROL]: false, + [LICENSE_FEATURES.GIT_CONNECTIONS]: false, [LICENSE_FEATURES.VARIABLES]: false, [LICENSE_FEATURES.API_DISABLED]: false, [LICENSE_FEATURES.EXTERNAL_SECRETS]: false, diff --git a/packages/cli/src/modules/git-connections.ee/__tests__/git-connections-git.service.test.ts b/packages/cli/src/modules/git-connections.ee/__tests__/git-connections-git.service.test.ts new file mode 100644 index 00000000000..8b4acc8d3a3 --- /dev/null +++ b/packages/cli/src/modules/git-connections.ee/__tests__/git-connections-git.service.test.ts @@ -0,0 +1,104 @@ +import { mockLogger } from '@n8n/backend-test-utils'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; + +import { GitConnectionsGitService } from '../git-connections-git.service'; + +describe('GitConnectionsGitService', () => { + const service = new GitConnectionsGitService(mockLogger()); + + describe('validateRepositoryUrl (https)', () => { + it('accepts an https URL without embedded credentials', () => { + expect(() => + service.validateRepositoryUrl('https://github.com/org/repo.git', 'https'), + ).not.toThrow(); + }); + + it('accepts http URLs (internal Git / Gitea on a trusted network)', () => { + expect(() => + service.validateRepositoryUrl('http://gitea.internal/org/repo.git', 'https'), + ).not.toThrow(); + }); + + it('rejects a URL that embeds credentials', () => { + expect(() => + service.validateRepositoryUrl('https://user:pass@github.com/org/repo.git', 'https'), + ).toThrow(BadRequestError); + }); + + it('rejects a non-URL string', () => { + expect(() => service.validateRepositoryUrl('not a url', 'https')).toThrow(BadRequestError); + }); + }); + + describe('control characters', () => { + it.each([ + ['tab', 'https://github.com/org/re\tpo.git'], + ['carriage return', 'https://github.com/org/re\rpo.git'], + ['newline', 'https://github.com/org/re\npo.git'], + ])('rejects an https URL containing a %s', (_label, url) => { + expect(() => service.validateRepositoryUrl(url, 'https')).toThrow(BadRequestError); + }); + + it.each([ + ['tab', 'git@github.com:org/re\tpo.git'], + ['carriage return', 'git@github.com:org/re\rpo.git'], + ['newline', 'git@github.com:org/re\npo.git'], + ])('rejects an ssh URL containing a %s', (_label, url) => { + expect(() => service.validateRepositoryUrl(url, 'ssh')).toThrow(BadRequestError); + }); + }); + + describe('validateRepositoryUrl (ssh)', () => { + it.each([ + 'ssh://git@github.com/org/repo.git', + 'ssh://git@github.com:22/org/repo.git', + 'git@github.com:org/repo.git', + 'github.com:org/repo.git', + ])('accepts %s', (url) => { + expect(() => service.validateRepositoryUrl(url, 'ssh')).not.toThrow(); + }); + + it.each([ + ['ext:: transport (command execution)', 'ext::sh -c "id"'], + ['file:// scheme (local repo disclosure)', 'file:///srv/private-repo'], + ['http:// scheme', 'http://github.com/org/repo.git'], + ['git:// scheme', 'git://github.com/org/repo.git'], + ['leading dash (option injection)', '--upload-pack=/tmp/x'], + ['host starting with a dash', 'user@-oProxyCommand=evil:path'], + ['bare ssh:// with no path', 'ssh://github.com'], + ['ssh:// with a password in the userinfo', 'ssh://git:secret@github.com/org/repo.git'], + ])('rejects %s', (_label, url) => { + expect(() => service.validateRepositoryUrl(url, 'ssh')).toThrow(BadRequestError); + }); + + describe('drive-letter prefixes', () => { + const originalPlatform = process.platform; + + const setPlatform = (platform: NodeJS.Platform) => { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + }; + + afterEach(() => { + setPlatform(originalPlatform); + }); + + // git reads `C:\path` / `C:/path` as a local filesystem path only on + // Windows, where allowing it would clone off the host's disk. + it.each(['C:/Users/n8n/repo', 'C:\\Users\\n8n\\repo', 'c:repo'])( + 'rejects %s on Windows', + (url) => { + setPlatform('win32'); + expect(() => service.validateRepositoryUrl(url, 'ssh')).toThrow(BadRequestError); + }, + ); + + // On other platforms git treats `c:path` as a scp-like remote to a + // one-character host, which is a legitimate SSH alias. + it.each(['C:/Users/n8n/repo', 'c:repo'])('accepts %s on non-Windows', (url) => { + setPlatform('linux'); + expect(() => service.validateRepositoryUrl(url, 'ssh')).not.toThrow(); + }); + }); + }); +}); diff --git a/packages/cli/src/modules/git-connections.ee/__tests__/git-connections-git.utils.test.ts b/packages/cli/src/modules/git-connections.ee/__tests__/git-connections-git.utils.test.ts new file mode 100644 index 00000000000..50eb3805a46 --- /dev/null +++ b/packages/cli/src/modules/git-connections.ee/__tests__/git-connections-git.utils.test.ts @@ -0,0 +1,148 @@ +import { + buildHttpsGitConfig, + buildSshCommand, + generateSshKeyPair, +} from '../git-connections-git.utils'; + +describe('git-connections-git.utils', () => { + describe('buildHttpsGitConfig', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + for (const key of [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'http_proxy', + 'https_proxy', + 'NO_PROXY', + 'no_proxy', + 'ALL_PROXY', + 'all_proxy', + ]) { + delete process.env[key]; + } + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should build a path-scoped credential helper for plain credentials', () => { + const config = buildHttpsGitConfig('https://github.com/user/repo.git', { + username: 'testuser', + password: 'testpass', + }); + + expect(config).toEqual([ + "credential.helper=!f() { echo username='testuser'; echo password='testpass'; }; f", + 'credential.useHttpPath=true', + 'http.lowSpeedLimit=1000', + 'http.lowSpeedTime=30', + ]); + }); + + it('should escape single quotes so credentials cannot break out of the helper', () => { + const config = buildHttpsGitConfig('https://github.com/user/repo.git', { + username: "user'; rm -rf /", + password: "pass'; rm -rf /", + }); + + expect(config[0]).toBe( + "credential.helper=!f() { echo username='user'\"'\"'; rm -rf /'; echo password='pass'\"'\"'; rm -rf /'; }; f", + ); + }); + + it('should append http.proxy when a proxy resolves for the repository URL', () => { + process.env.HTTPS_PROXY = 'http://proxy.company.com:8080'; + + const config = buildHttpsGitConfig('https://github.com/user/repo.git', { + username: 'testuser', + password: 'testpass', + }); + + expect(config).toContain('http.proxy=http://proxy.company.com:8080'); + }); + + it('should not append http.proxy when no proxy is set', () => { + const config = buildHttpsGitConfig('https://github.com/user/repo.git', { + username: 'testuser', + password: 'testpass', + }); + + expect(config.some((entry) => entry.includes('proxy='))).toBe(false); + }); + + it('should bound stalled transfers with a low-speed limit', () => { + const config = buildHttpsGitConfig('https://github.com/user/repo.git', { + username: 'testuser', + password: 'testpass', + }); + + expect(config).toContain('http.lowSpeedLimit=1000'); + expect(config).toContain('http.lowSpeedTime=30'); + }); + }); + + describe('buildSshCommand', () => { + it('should build an ssh command pinning host keys with accept-new', () => { + const command = buildSshCommand({ + privateKeyPath: '/data/.ssh/private-key', + knownHostsPath: '/data/.ssh/known_hosts', + }); + + expect(command).toBe( + "ssh -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o UserKnownHostsFile='/data/.ssh/known_hosts' -o StrictHostKeyChecking=accept-new -i '/data/.ssh/private-key'", + ); + }); + + it('should normalize Windows-style backslash paths to POSIX', () => { + const command = buildSshCommand({ + privateKeyPath: 'C:\\n8n\\.ssh\\private-key', + knownHostsPath: 'C:\\n8n\\.ssh\\known_hosts', + }); + + expect(command).toBe( + "ssh -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o UserKnownHostsFile='C:/n8n/.ssh/known_hosts' -o StrictHostKeyChecking=accept-new -i 'C:/n8n/.ssh/private-key'", + ); + }); + + it('should bound a stalled connection with connect and keepalive timeouts', () => { + const command = buildSshCommand({ + privateKeyPath: '/data/.ssh/private-key', + knownHostsPath: '/data/.ssh/known_hosts', + }); + + expect(command).toContain('-o ConnectTimeout=30'); + expect(command).toContain('-o ServerAliveInterval=15'); + expect(command).toContain('-o ServerAliveCountMax=3'); + }); + + it('should quote shell metacharacters in paths', () => { + const command = buildSshCommand({ + privateKeyPath: "/data/$(archive)/owner's/private-key", + knownHostsPath: '/data/`archive`/known_hosts', + }); + + expect(command).toContain("-i '/data/$(archive)/owner'\"'\"'s/private-key'"); + expect(command).toContain("-o UserKnownHostsFile='/data/`archive`/known_hosts'"); + }); + }); + + describe('generateSshKeyPair', () => { + it('should generate a parseable ed25519 key pair carrying the comment', async () => { + const keyPair = await generateSshKeyPair('ed25519', 'n8n git connection'); + + expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY'); + expect(keyPair.publicKey).toContain('ssh-ed25519'); + expect(keyPair.publicKey).toContain('n8n git connection'); + }); + + it('should generate a parseable rsa key pair', async () => { + const keyPair = await generateSshKeyPair('rsa', 'n8n git connection'); + + expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY'); + expect(keyPair.publicKey).toContain('ssh-rsa'); + }); + }); +}); diff --git a/packages/cli/src/modules/git-connections.ee/__tests__/git-connections.service.test.ts b/packages/cli/src/modules/git-connections.ee/__tests__/git-connections.service.test.ts new file mode 100644 index 00000000000..45082d84dec --- /dev/null +++ b/packages/cli/src/modules/git-connections.ee/__tests__/git-connections.service.test.ts @@ -0,0 +1,194 @@ +import type { CreateGitConnectionDto, UpdateGitConnectionDto } from '@n8n/api-types'; +import type { Cipher, InstanceSettings } from 'n8n-core'; +import { mock } from 'vitest-mock-extended'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; + +import type { GitConnection } from '../database/entities/git-connection.entity'; +import type { GitConnectionRepository } from '../database/repositories/git-connection.repository'; +import type { GitConnectionsGitService } from '../git-connections-git.service'; +import { GitConnectionsService } from '../git-connections.service'; + +describe('GitConnectionsService (credential state machine)', () => { + const repository = mock(); + const gitService = mock(); + const cipher = mock(); + const instanceSettings = mock({ n8nFolder: '/tmp/n8n' }); + + const service = new GitConnectionsService(repository, gitService, cipher, instanceSettings); + + const baseEntity = () => ({ + id: '1', + name: 'c', + repositoryUrl: 'git@github.com:o/r.git', + branchName: 'main', + createdAt: new Date(), + updatedAt: new Date(), + }); + + const sshEntity = (): GitConnection => + ({ + ...baseEntity(), + connectionType: 'ssh', + keyGeneratorType: 'ed25519', + publicKey: 'PUB', + encryptedPrivateKey: 'enc:PRIV', + encryptedUsername: null, + encryptedPassword: null, + }) as GitConnection; + + const httpsEntity = (): GitConnection => + ({ + ...baseEntity(), + repositoryUrl: 'https://github.com/o/r.git', + connectionType: 'https', + keyGeneratorType: null, + publicKey: null, + encryptedPrivateKey: null, + encryptedUsername: 'enc:user', + encryptedPassword: 'enc:pass', + }) as GitConnection; + + beforeEach(() => { + vi.clearAllMocks(); + repository.create.mockImplementation((input) => input as GitConnection); + repository.save.mockImplementation(async (input) => { + const entity = input as GitConnection; + entity.createdAt ??= new Date(); + entity.updatedAt ??= new Date(); + return entity; + }); + cipher.encryptV2.mockImplementation(async (value) => `enc:${value as string}`); + gitService.generateSshKeyPair.mockResolvedValue({ publicKey: 'PUB', privateKey: 'PRIV' }); + }); + + describe('create', () => { + it('generates and encrypts an SSH key pair for ssh connections', async () => { + await service.create({ + name: 'c', + repositoryUrl: 'git@github.com:o/r.git', + connectionType: 'ssh', + } as CreateGitConnectionDto); + + const saved = repository.save.mock.calls[0][0] as GitConnection; + expect(gitService.generateSshKeyPair).toHaveBeenCalledWith('ed25519'); + expect(saved.publicKey).toBe('PUB'); + expect(saved.encryptedPrivateKey).toBe('enc:PRIV'); + expect(saved.encryptedUsername).toBeNull(); + }); + + it('rejects username/password on an ssh connection', async () => { + await expect( + service.create({ + name: 'c', + repositoryUrl: 'git@github.com:o/r.git', + connectionType: 'ssh', + username: 'u', + password: 'p', + } as CreateGitConnectionDto), + ).rejects.toThrow(BadRequestError); + }); + + it('encrypts username and password for https connections', async () => { + await service.create({ + name: 'c', + repositoryUrl: 'https://github.com/o/r.git', + connectionType: 'https', + username: 'u', + password: 'p', + } as CreateGitConnectionDto); + + const saved = repository.save.mock.calls[0][0] as GitConnection; + expect(saved.encryptedUsername).toBe('enc:u'); + expect(saved.encryptedPassword).toBe('enc:p'); + expect(saved.encryptedPrivateKey).toBeNull(); + }); + + it('requires username and password together for https connections', async () => { + await expect( + service.create({ + name: 'c', + repositoryUrl: 'https://github.com/o/r.git', + connectionType: 'https', + username: 'u', + } as CreateGitConnectionDto), + ).rejects.toThrow(BadRequestError); + }); + + it('rejects keyGeneratorType on an https connection', async () => { + await expect( + service.create({ + name: 'c', + repositoryUrl: 'https://github.com/o/r.git', + connectionType: 'https', + username: 'u', + password: 'p', + keyGeneratorType: 'rsa', + } as CreateGitConnectionDto), + ).rejects.toThrow(BadRequestError); + }); + }); + + describe('update', () => { + it('rejects changing the SSH key type after creation', async () => { + repository.findOneBy.mockResolvedValue(sshEntity()); + await expect( + service.update('1', { keyGeneratorType: 'rsa' } as UpdateGitConnectionDto), + ).rejects.toThrow(BadRequestError); + }); + + it('keeps existing keys when an ssh connection is updated without key changes', async () => { + repository.findOneBy.mockResolvedValue(sshEntity()); + await service.update('1', { name: 'renamed' } as UpdateGitConnectionDto); + + expect(gitService.generateSshKeyPair).not.toHaveBeenCalled(); + const saved = repository.save.mock.calls[0][0] as GitConnection; + expect(saved.encryptedPrivateKey).toBe('enc:PRIV'); + }); + + it('clears the opposite credential set when switching https -> ssh', async () => { + repository.findOneBy.mockResolvedValue(httpsEntity()); + await service.update('1', { connectionType: 'ssh' } as UpdateGitConnectionDto); + + const saved = repository.save.mock.calls[0][0] as GitConnection; + expect(saved.encryptedPrivateKey).toBe('enc:PRIV'); + expect(saved.encryptedUsername).toBeNull(); + expect(saved.encryptedPassword).toBeNull(); + }); + + it('requires credentials and clears keys when switching ssh -> https', async () => { + repository.findOneBy.mockResolvedValue(sshEntity()); + await expect( + service.update('1', { connectionType: 'https' } as UpdateGitConnectionDto), + ).rejects.toThrow(BadRequestError); + + repository.findOneBy.mockResolvedValue(sshEntity()); + await service.update('1', { + connectionType: 'https', + username: 'u', + password: 'p', + } as UpdateGitConnectionDto); + + const saved = repository.save.mock.calls.at(-1)![0] as GitConnection; + expect(saved.encryptedUsername).toBe('enc:u'); + expect(saved.encryptedPassword).toBe('enc:p'); + expect(saved.encryptedPrivateKey).toBeNull(); + expect(saved.publicKey).toBeNull(); + expect(saved.keyGeneratorType).toBeNull(); + }); + + it('invalidates the cached working copy on a branch-only change', async () => { + repository.findOneBy.mockResolvedValue(sshEntity()); + await service.update('1', { branchName: 'release' } as UpdateGitConnectionDto); + + expect(gitService.resetWorkingCopy).toHaveBeenCalledWith('/tmp/n8n/git-connections/1'); + }); + + it('does not touch the working copy on a name-only change', async () => { + repository.findOneBy.mockResolvedValue(sshEntity()); + await service.update('1', { name: 'renamed' } as UpdateGitConnectionDto); + + expect(gitService.resetWorkingCopy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/modules/git-connections.ee/constants.ts b/packages/cli/src/modules/git-connections.ee/constants.ts new file mode 100644 index 00000000000..8e2e9370429 --- /dev/null +++ b/packages/cli/src/modules/git-connections.ee/constants.ts @@ -0,0 +1,23 @@ +/** Comment embedded in generated SSH keys (visible when added as a deploy key). */ +export const GIT_KEY_COMMENT = 'n8n git connection'; + +// Timeouts that bound how long a stalled Git operation can hang. Transport-level +// limits (SSH, HTTP) fire first based on real network inactivity; the process +// stall timeout is a transport-agnostic backstop. + +// Bound SSH: fail the initial connect after CONNECT_TIMEOUT, and drop an +// established-but-silent session after INTERVAL * COUNT_MAX seconds of unanswered +// keepalives. +export const SSH_CONNECT_TIMEOUT_SECONDS = 30; +export const SSH_SERVER_ALIVE_INTERVAL_SECONDS = 15; +export const SSH_SERVER_ALIVE_COUNT_MAX = 3; + +// Abort an HTTP(S) transfer that drops below ~1 KB/s for this long, so a stalled +// server can't leave the request pending indefinitely. +export const HTTP_LOW_SPEED_LIMIT_BYTES = 1000; +export const HTTP_LOW_SPEED_TIME_SECONDS = 30; + +// Transport-agnostic backstop: kill the git process if it emits no output for +// this long, so clone() always rejects and its cleanup path runs. Clone is run +// with --progress so a healthy transfer keeps feeding this timer. +export const GIT_COMMAND_STALL_TIMEOUT_MS = 60_000; diff --git a/packages/cli/src/modules/git-connections.ee/database/entities/git-connection.entity.ts b/packages/cli/src/modules/git-connections.ee/database/entities/git-connection.entity.ts index 32b3976a2d2..875b1344ce6 100644 --- a/packages/cli/src/modules/git-connections.ee/database/entities/git-connection.entity.ts +++ b/packages/cli/src/modules/git-connections.ee/database/entities/git-connection.entity.ts @@ -1,11 +1,7 @@ +import type { GitConnectionType, GitKeyGeneratorType } from '@n8n/api-types'; import { WithTimestampsAndStringId } from '@n8n/db'; import { Column, Entity } from '@n8n/typeorm'; -// Defined inline so this DB-layer entity stays self-contained; the shared -// @n8n/api-types DTOs that own these unions arrive in a later PR. -type GitConnectionType = 'ssh' | 'https'; -type GitKeyGeneratorType = 'ed25519' | 'rsa'; - @Entity('git_connection') export class GitConnection extends WithTimestampsAndStringId { @Column({ type: 'varchar', length: 128 }) diff --git a/packages/cli/src/modules/git-connections.ee/database/repositories/git-connection.repository.ts b/packages/cli/src/modules/git-connections.ee/database/repositories/git-connection.repository.ts new file mode 100644 index 00000000000..0171c828259 --- /dev/null +++ b/packages/cli/src/modules/git-connections.ee/database/repositories/git-connection.repository.ts @@ -0,0 +1,26 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { GitConnection } from '../entities/git-connection.entity'; + +@Service() +export class GitConnectionRepository extends Repository { + constructor(dataSource: DataSource) { + super(GitConnection, dataSource.manager); + } + + async getManyAndCount(options: { skip: number; take: number }) { + // TypeORM omits the LIMIT clause when `take` is falsy, so `take: 0` would + // return every row instead of none — short-circuit to honor the limit. + if (options.take <= 0) { + return { count: await this.count(), data: [] }; + } + + const [data, count] = await this.findAndCount({ + order: { id: 'ASC' }, + skip: options.skip, + take: options.take, + }); + return { count, data }; + } +} diff --git a/packages/cli/src/modules/git-connections.ee/git-connections-git.service.ts b/packages/cli/src/modules/git-connections.ee/git-connections-git.service.ts new file mode 100644 index 00000000000..7a845210584 --- /dev/null +++ b/packages/cli/src/modules/git-connections.ee/git-connections-git.service.ts @@ -0,0 +1,250 @@ +import type { GitConnectionType, GitKeyGeneratorType } from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; +import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { SimpleGit, SimpleGitOptions } from 'simple-git'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; + +import { GIT_COMMAND_STALL_TIMEOUT_MS, GIT_KEY_COMMENT } from './constants'; +import type { GitConnection } from './database/entities/git-connection.entity'; +import { + buildHttpsGitConfig, + buildSshCommand, + generateSshKeyPair, +} from './git-connections-git.utils'; + +type PlainCredentials = + | { type: 'ssh'; privateKey: string } + | { type: 'https'; username: string; password: string }; + +@Service() +export class GitConnectionsGitService { + constructor(private readonly logger: Logger) { + this.logger = this.logger.scoped('git-connections'); + } + + validateRepositoryUrl(repositoryUrl: string, connectionType: GitConnectionType) { + // `new URL()` silently strips control characters, so reject them up front to + // keep the persisted/git-handed URL identical to what we validated. + for (const char of repositoryUrl) { + const code = char.charCodeAt(0); + if (code < 32 || code === 127) { + throw new BadRequestError('Repository URL must not contain control characters'); + } + } + if (connectionType === 'https') { + this.validateHttpsRepositoryUrl(repositoryUrl); + } else { + this.validateSshRepositoryUrl(repositoryUrl); + } + } + + /** + * Accept only `http(s)://` URLs without embedded credentials. Credentials must + * come through the encrypted fields, not the stored/returned `repositoryUrl`. + */ + private validateHttpsRepositoryUrl(repositoryUrl: string) { + let url: URL; + try { + url = new URL(repositoryUrl); + } catch { + throw new BadRequestError('Repository URL must be a valid HTTP or HTTPS URL'); + } + if (!['http:', 'https:'].includes(url.protocol)) { + throw new BadRequestError('HTTPS connections require an HTTP or HTTPS repository URL'); + } + if (url.username || url.password) { + throw new BadRequestError('Repository URL must not contain credentials'); + } + } + + /** + * Only accept `ssh://host/path` and scp-like `user@host:path`. Reject a + * leading `-` (option injection), the `::` transport-helper form (e.g. + * `ext::`, which runs arbitrary commands), and any non-ssh scheme (e.g. + * `file://`, `http://`) — git's remote transports would otherwise let a URL + * clone local paths off the host or execute commands. + */ + private validateSshRepositoryUrl(repositoryUrl: string) { + const error = new BadRequestError( + 'SSH connections require an ssh:// or user@host:path repository URL', + ); + + if (repositoryUrl.startsWith('-') || repositoryUrl.includes('::')) throw error; + + const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.exec(repositoryUrl); + if (scheme) { + if (scheme[1].toLowerCase() !== 'ssh') throw error; + let url: URL; + try { + url = new URL(repositoryUrl); + } catch { + throw error; + } + // A bare username is the legitimate SSH user (e.g. `git@`); a password in + // the userinfo would be stored/returned in `repositoryUrl` and treated by + // git as part of the SSH user. Credentials must come through the encrypted fields. + if (url.password) throw new BadRequestError('Repository URL must not contain credentials'); + if (!url.hostname || !url.pathname || url.pathname === '/') throw error; + return; + } + + // On Windows, git reads a drive-letter prefix (`C:\path`, `C:/path`) as a + // local filesystem path rather than a `host:path` remote, so it would clone + // off the host's disk. Reject it before the scp-like check treats the drive + // letter as a hostname. This is a Windows-only interpretation; on other + // platforms git treats `c:path` as a scp-like remote to host `c`, which is a + // legitimate one-character SSH alias, so only guard when running on Windows. + if (process.platform === 'win32' && /^[a-zA-Z]:/.test(repositoryUrl)) throw error; + + // scp-like: [user@]host:path, host must not start with `-`. + const isScpLike = /^(?:[a-zA-Z0-9_.-]+@)?[a-zA-Z0-9._][a-zA-Z0-9._-]*:[^\s]+$/.test( + repositoryUrl, + ); + if (!isScpLike) throw error; + } + + async validateBranchName(branchName: string) { + const { simpleGit } = await import('simple-git'); + try { + await simpleGit().raw(['check-ref-format', '--branch', branchName]); + } catch { + throw new BadRequestError(`Invalid Git branch name: ${branchName}`); + } + } + + async generateSshKeyPair(keyType: GitKeyGeneratorType) { + return await generateSshKeyPair(keyType, GIT_KEY_COMMENT); + } + + async clone({ + connection, + credentials, + branchName, + rootFolder, + }: { + connection: GitConnection; + credentials: PlainCredentials; + branchName: string; + rootFolder: string; + }) { + await this.validateBranchName(branchName); + await mkdir(rootFolder, { recursive: true }); + const repositoryFolder = path.join(rootFolder, 'repository'); + const nextRepositoryFolder = path.join(rootFolder, 'repository-next'); + await rm(nextRepositoryFolder, { recursive: true, force: true }); + + try { + await this.withGit({ connection, credentials, baseDir: rootFolder }, async (git) => { + const remoteRefs = await git.listRemote([ + '--heads', + connection.repositoryUrl, + `refs/heads/${branchName}`, + ]); + if (!remoteRefs.trim()) { + throw new BadRequestError(`Remote branch does not exist: ${branchName}`); + } + await git.clone(connection.repositoryUrl, nextRepositoryFolder, [ + '--branch', + branchName, + '--single-branch', + '--no-tags', + // Emit progress so the stall-timeout backstop keeps resetting during a + // healthy transfer and only fires when the connection genuinely stalls. + '--progress', + ]); + await rm(repositoryFolder, { recursive: true, force: true }); + await rename(nextRepositoryFolder, repositoryFolder); + }); + } catch (error) { + await rm(nextRepositoryFolder, { recursive: true, force: true }); + if (error instanceof BadRequestError) throw error; + // Withhold the raw git output (it can echo the credential-helper config); + // log only a redacted subset that lets an operator correlate the failure. + this.logger.warn('Failed to connect to Git repository', { + connectionId: connection.id, + branchName, + }); + throw new BadRequestError('Could not connect to the Git repository'); + } + } + + /** + * Remove the cached working copy but keep `.ssh/known_hosts`, so a pinned + * host key survives credential rotation and reconnects (preserving MITM + * protection). Used when auth/target changes invalidate the clone. + */ + async resetWorkingCopy(rootFolder: string) { + await rm(path.join(rootFolder, 'repository'), { recursive: true, force: true }); + await rm(path.join(rootFolder, 'repository-next'), { recursive: true, force: true }); + } + + /** Remove everything for a connection, including the pinned host key. */ + async cleanup(rootFolder: string) { + await rm(rootFolder, { recursive: true, force: true }); + } + + /** + * Run a git operation with credentials wired in and torn down afterwards. + * Auth is configured per connection type: HTTPS goes through a credential + * helper, SSH writes the private key to a temporary file referenced via + * `GIT_SSH_COMMAND`. The `finally` block removes that temporary key material + * so it never outlives the operation, regardless of success or failure. + */ + private async withGit( + { + connection, + credentials, + baseDir, + }: { + connection: GitConnection; + credentials: PlainCredentials; + baseDir: string; + }, + operation: (git: SimpleGit) => Promise, + ) { + await mkdir(baseDir, { recursive: true }); + const options: Partial = { + baseDir, + binary: 'git', + maxConcurrentProcesses: 1, + trimmed: false, + timeout: { block: GIT_COMMAND_STALL_TIMEOUT_MS }, + }; + const { simpleGit } = await import('simple-git'); + let temporaryFolder: string | undefined; + + try { + let git: SimpleGit; + if (credentials.type === 'https') { + const config = buildHttpsGitConfig(connection.repositoryUrl, credentials); + + git = simpleGit({ + ...options, + config, + unsafe: { allowUnsafeCredentialHelper: true }, + }).env('GIT_TERMINAL_PROMPT', '0'); + } else { + temporaryFolder = await mkdtemp(path.join(tmpdir(), 'n8n-git-connection-')); + const privateKeyPath = path.join(temporaryFolder, 'private-key'); + await writeFile(privateKeyPath, credentials.privateKey, { mode: 0o600 }); + await chmod(privateKeyPath, 0o600); + const sshFolder = path.join(baseDir, '.ssh'); + await mkdir(sshFolder, { recursive: true }); + const sshCommand = buildSshCommand({ + privateKeyPath, + knownHostsPath: path.join(sshFolder, 'known_hosts'), + }); + git = simpleGit({ ...options, unsafe: { allowUnsafeSshCommand: true } }) + .env('GIT_SSH_COMMAND', sshCommand) + .env('GIT_TERMINAL_PROMPT', '0'); + } + return await operation(git); + } finally { + if (temporaryFolder) await rm(temporaryFolder, { recursive: true, force: true }); + } + } +} diff --git a/packages/cli/src/modules/git-connections.ee/git-connections-git.utils.ts b/packages/cli/src/modules/git-connections.ee/git-connections-git.utils.ts new file mode 100644 index 00000000000..778ad0cf253 --- /dev/null +++ b/packages/cli/src/modules/git-connections.ee/git-connections-git.utils.ts @@ -0,0 +1,69 @@ +import type { GitKeyGeneratorType } from '@n8n/api-types'; +import { resolveProxyUrl } from '@n8n/backend-network'; +import { generateKeyPairSync } from 'node:crypto'; + +import { + HTTP_LOW_SPEED_LIMIT_BYTES, + HTTP_LOW_SPEED_TIME_SECONDS, + SSH_CONNECT_TIMEOUT_SECONDS, + SSH_SERVER_ALIVE_COUNT_MAX, + SSH_SERVER_ALIVE_INTERVAL_SECONDS, +} from './constants'; + +/** Quote a value for use as one POSIX shell argument. */ +const quoteShellArg = (value: string) => `'${value.replace(/'/g, "'\"'\"'")}'`; + +/** + * Build the Git configuration for an HTTPS connection. + * + * The credential helper serves every request from this Git process. Each + * connection uses a separate process and configuration. + */ +export function buildHttpsGitConfig( + repositoryUrl: string, + credentials: { username: string; password: string }, +): string[] { + const helper = `!f() { echo username=${quoteShellArg(credentials.username)}; echo password=${quoteShellArg(credentials.password)}; }; f`; + const config = [ + `credential.helper=${helper}`, + 'credential.useHttpPath=true', + `http.lowSpeedLimit=${HTTP_LOW_SPEED_LIMIT_BYTES}`, + `http.lowSpeedTime=${HTTP_LOW_SPEED_TIME_SECONDS}`, + ]; + // Git uses http.proxy for both HTTP and HTTPS URLs. + const proxyUrl = resolveProxyUrl(repositoryUrl); + if (proxyUrl) config.push(`http.proxy=${proxyUrl}`); + return config; +} + +/** Build the shell-safe SSH command used for Git connections. */ +export function buildSshCommand(paths: { privateKeyPath: string; knownHostsPath: string }): string { + const normalizeAndQuote = (value: string) => quoteShellArg(value.split(/[/\\]/).join('/')); + const timeouts = `-o ConnectTimeout=${SSH_CONNECT_TIMEOUT_SECONDS} -o ServerAliveInterval=${SSH_SERVER_ALIVE_INTERVAL_SECONDS} -o ServerAliveCountMax=${SSH_SERVER_ALIVE_COUNT_MAX}`; + return `ssh ${timeouts} -o UserKnownHostsFile=${normalizeAndQuote(paths.knownHostsPath)} -o StrictHostKeyChecking=accept-new -i ${normalizeAndQuote(paths.privateKeyPath)}`; +} + +/** Generate an OpenSSH key pair with the supplied key comment. */ +export async function generateSshKeyPair(keyType: GitKeyGeneratorType, comment: string) { + // Read the CommonJS default export because parsePrivateKey is not a named export. + const { default: sshpk } = await import('sshpk'); + const generated = + keyType === 'ed25519' + ? generateKeyPairSync('ed25519', { + privateKeyEncoding: { format: 'pem', type: 'pkcs8' }, + publicKeyEncoding: { format: 'pem', type: 'spki' }, + }) + : generateKeyPairSync('rsa', { + modulusLength: 4096, + privateKeyEncoding: { format: 'pem', type: 'pkcs8' }, + publicKeyEncoding: { format: 'pem', type: 'spki' }, + }); + const publicKey = sshpk.parseKey(generated.publicKey, 'pem'); + publicKey.comment = comment; + const privateKey = sshpk.parsePrivateKey(generated.privateKey, 'pem'); + privateKey.comment = comment; + return { + publicKey: publicKey.toString('ssh'), + privateKey: privateKey.toString('ssh-private'), + }; +} diff --git a/packages/cli/src/modules/git-connections.ee/git-connections.module.ts b/packages/cli/src/modules/git-connections.ee/git-connections.module.ts new file mode 100644 index 00000000000..ff43925621a --- /dev/null +++ b/packages/cli/src/modules/git-connections.ee/git-connections.module.ts @@ -0,0 +1,21 @@ +import { LICENSE_FEATURES } from '@n8n/constants'; +import type { ModuleInterface } from '@n8n/decorators'; +import { BackendModule } from '@n8n/decorators'; +import { Container } from '@n8n/di'; + +@BackendModule({ + name: 'git-connections', + licenseFlag: LICENSE_FEATURES.GIT_CONNECTIONS, + instanceTypes: ['main'], +}) +export class GitConnectionsModule implements ModuleInterface { + async init() { + const { GitConnectionsService } = await import('./git-connections.service.js'); + Container.get(GitConnectionsService); + } + + async entities() { + const { GitConnection } = await import('./database/entities/git-connection.entity.js'); + return [GitConnection]; + } +} diff --git a/packages/cli/src/modules/git-connections.ee/git-connections.service.ts b/packages/cli/src/modules/git-connections.ee/git-connections.service.ts new file mode 100644 index 00000000000..f319daad240 --- /dev/null +++ b/packages/cli/src/modules/git-connections.ee/git-connections.service.ts @@ -0,0 +1,249 @@ +import type { + CreateGitConnectionDto, + GitConnectionPublicDto, + UpdateGitConnectionDto, +} from '@n8n/api-types'; +import { Service } from '@n8n/di'; +import { Cipher, InstanceSettings } from 'n8n-core'; +import path from 'node:path'; + +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + +import { GitConnection } from './database/entities/git-connection.entity'; +import { GitConnectionRepository } from './database/repositories/git-connection.repository'; +import { GitConnectionsGitService } from './git-connections-git.service'; + +@Service() +export class GitConnectionsService { + constructor( + private readonly repository: GitConnectionRepository, + private readonly gitService: GitConnectionsGitService, + private readonly cipher: Cipher, + private readonly instanceSettings: InstanceSettings, + ) {} + + async create(input: CreateGitConnectionDto) { + this.gitService.validateRepositoryUrl(input.repositoryUrl, input.connectionType); + if (input.branchName) await this.gitService.validateBranchName(input.branchName); + + const connection = this.repository.create({ + name: input.name, + repositoryUrl: input.repositoryUrl, + branchName: input.branchName ?? null, + connectionType: input.connectionType, + publicKey: null, + encryptedPrivateKey: null, + encryptedUsername: null, + encryptedPassword: null, + keyGeneratorType: null, + baseCommit: null, + }); + await this.applyNewAuthentication(connection, input); + return this.toPublic(await this.repository.save(connection)); + } + + async findOne(id: string) { + return this.toPublic(await this.getEntity(id)); + } + + async list(offset: number, limit: number) { + const result = await this.repository.getManyAndCount({ skip: offset, take: limit }); + return { ...result, data: result.data.map((connection) => this.toSummary(connection)) }; + } + + async update(id: string, input: UpdateGitConnectionDto) { + if (Object.keys(input).length === 0) + throw new BadRequestError('At least one field is required'); + const current = await this.getEntity(id); + const targetType = input.connectionType ?? current.connectionType; + const targetUrl = input.repositoryUrl ?? current.repositoryUrl; + this.gitService.validateRepositoryUrl(targetUrl, targetType); + if (input.branchName) await this.gitService.validateBranchName(input.branchName); + + // Any auth or target (url/branch) change invalidates the cached working + // copy on disk — the branch is part of the target, so a branch-only change + // must invalidate too, otherwise the clone stays on the old branch. + const changesTarget = + input.connectionType !== undefined || + input.repositoryUrl !== undefined || + input.branchName !== undefined || + input.username !== undefined || + input.password !== undefined || + input.keyGeneratorType !== undefined; + + const updated = this.repository.create({ ...current }); + if (input.name !== undefined) updated.name = input.name; + if (input.repositoryUrl !== undefined) updated.repositoryUrl = input.repositoryUrl; + if (input.branchName !== undefined) updated.branchName = input.branchName; + + await this.applyUpdatedAuthentication(updated, current, input); + + const saved = await this.repository.save(updated); + if (changesTarget) await this.resetWorkingCopy(id); + return this.toPublic(saved); + } + + async connect(id: string, branchName?: string) { + const connection = await this.getEntity(id); + const effectiveBranch = branchName ?? connection.branchName; + if (!effectiveBranch) throw new BadRequestError('A branch name is required to connect'); + const credentials = await this.decryptCredentials(connection); + await this.gitService.clone({ + connection, + credentials, + branchName: effectiveBranch, + rootFolder: this.rootFolder(id), + }); + connection.branchName = effectiveBranch; + return this.toPublic(await this.repository.save(connection)); + } + + async disconnect(id: string) { + const connection = await this.getEntity(id); + await this.resetWorkingCopy(id); + return this.toPublic(connection); + } + + async delete(id: string) { + const connection = await this.getEntity(id); + await this.purge(id); + await this.repository.remove(connection); + } + + private async applyNewAuthentication( + connection: GitConnection, + input: Pick< + CreateGitConnectionDto, + 'connectionType' | 'keyGeneratorType' | 'username' | 'password' + >, + ) { + if (input.connectionType === 'ssh') { + if (input.username !== undefined || input.password !== undefined) { + throw new BadRequestError('Username and password are only valid for HTTPS connections'); + } + const keyType = input.keyGeneratorType ?? 'ed25519'; + const keyPair = await this.gitService.generateSshKeyPair(keyType); + connection.publicKey = keyPair.publicKey; + connection.encryptedPrivateKey = await this.cipher.encryptV2(keyPair.privateKey); + connection.keyGeneratorType = keyType; + return; + } + + if (input.keyGeneratorType !== undefined) { + throw new BadRequestError('Key generator type is only valid for SSH connections'); + } + this.validateHttpsCredentials(input.username, input.password, true); + connection.encryptedUsername = await this.cipher.encryptV2(input.username!); + connection.encryptedPassword = await this.cipher.encryptV2(input.password!); + } + + private async applyUpdatedAuthentication( + updated: GitConnection, + current: GitConnection, + input: UpdateGitConnectionDto, + ) { + const targetType = input.connectionType ?? current.connectionType; + if (targetType === 'ssh') { + if (input.username !== undefined || input.password !== undefined) { + throw new BadRequestError('Username and password are only valid for HTTPS connections'); + } + if (current.connectionType === 'ssh') { + if (input.keyGeneratorType && input.keyGeneratorType !== current.keyGeneratorType) { + throw new BadRequestError('SSH key type cannot be changed after creation'); + } + return; + } + const keyType = input.keyGeneratorType ?? 'ed25519'; + const pair = await this.gitService.generateSshKeyPair(keyType); + updated.connectionType = 'ssh'; + updated.publicKey = pair.publicKey; + updated.encryptedPrivateKey = await this.cipher.encryptV2(pair.privateKey); + updated.keyGeneratorType = keyType; + updated.encryptedUsername = null; + updated.encryptedPassword = null; + return; + } + + if (input.keyGeneratorType !== undefined) { + throw new BadRequestError('Key generator type is only valid for SSH connections'); + } + const switching = current.connectionType === 'ssh'; + this.validateHttpsCredentials(input.username, input.password, switching); + updated.connectionType = 'https'; + if (input.username !== undefined && input.password !== undefined) { + updated.encryptedUsername = await this.cipher.encryptV2(input.username); + updated.encryptedPassword = await this.cipher.encryptV2(input.password); + } + updated.publicKey = null; + updated.encryptedPrivateKey = null; + updated.keyGeneratorType = null; + } + + private validateHttpsCredentials(username?: string, password?: string, required = false) { + if ((username === undefined) !== (password === undefined) || (required && !username)) { + throw new BadRequestError('HTTPS username and password must be provided together'); + } + if ([username, password].some((value) => value && /[\r\n\0]/.test(value))) { + throw new BadRequestError('HTTPS credentials contain unsupported characters'); + } + } + + private async decryptCredentials(connection: GitConnection) { + if (connection.connectionType === 'ssh') { + if (!connection.encryptedPrivateKey) throw new BadRequestError('SSH private key is missing'); + return { + type: 'ssh' as const, + privateKey: await this.cipher.decryptV2(connection.encryptedPrivateKey), + }; + } + if (!connection.encryptedUsername || !connection.encryptedPassword) { + throw new BadRequestError('HTTPS credentials are missing'); + } + return { + type: 'https' as const, + username: await this.cipher.decryptV2(connection.encryptedUsername), + password: await this.cipher.decryptV2(connection.encryptedPassword), + }; + } + + private async getEntity(id: string) { + const connection = await this.repository.findOneBy({ id }); + if (!connection) throw new NotFoundError('Git connection not found'); + return connection; + } + + private rootFolder(id: string) { + return path.join(this.instanceSettings.n8nFolder, 'git-connections', id); + } + + /** Drop the cached working copy but keep the pinned host key. */ + private async resetWorkingCopy(id: string) { + await this.gitService.resetWorkingCopy(this.rootFolder(id)); + } + + /** Remove everything on disk for a connection, including the pinned host key. */ + private async purge(id: string) { + await this.gitService.cleanup(this.rootFolder(id)); + } + + private toPublic(connection: GitConnection): GitConnectionPublicDto { + return { + id: connection.id, + name: connection.name, + repositoryUrl: connection.repositoryUrl, + branchName: connection.branchName, + connectionType: connection.connectionType, + publicKey: connection.publicKey, + keyGeneratorType: connection.keyGeneratorType, + baseCommit: connection.baseCommit, + createdAt: connection.createdAt.toISOString(), + updatedAt: connection.updatedAt.toISOString(), + }; + } + + private toSummary(connection: GitConnection) { + const { publicKey: _, ...summary } = this.toPublic(connection); + return summary; + } +} diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-git.service.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-git.service.test.ts index 0ae3a00516e..3dabb3eac9a 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-git.service.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-git.service.test.ts @@ -332,7 +332,12 @@ describe('SourceControlGitService', () => { binary: 'git', maxConcurrentProcesses: 6, trimmed: false, - config: [`credential.helper=${expectedCredentialScript}`, 'credential.useHttpPath=true'], + config: [ + `credential.helper=${expectedCredentialScript}`, + 'credential.useHttpPath=true', + 'http.lowSpeedLimit=1000', + 'http.lowSpeedTime=30', + ], unsafe: { allowUnsafeCredentialHelper: true }, }), ); @@ -359,7 +364,12 @@ describe('SourceControlGitService', () => { "!f() { echo username='user'\"'\"'; rm -rf /'; echo password='pass'\"'\"'; rm -rf /'; }; f"; expect(simpleGit).toHaveBeenCalledWith( expect.objectContaining({ - config: [`credential.helper=${expectedCredentialScript}`, 'credential.useHttpPath=true'], + config: [ + `credential.helper=${expectedCredentialScript}`, + 'credential.useHttpPath=true', + 'http.lowSpeedLimit=1000', + 'http.lowSpeedTime=30', + ], }), ); }); @@ -382,7 +392,7 @@ describe('SourceControlGitService', () => { ); expect(mockGitInstance.env).toHaveBeenCalledWith( 'GIT_SSH_COMMAND', - 'ssh -o UserKnownHostsFile=".ssh/known_hosts" -o StrictHostKeyChecking=accept-new -i "private-key"', + "ssh -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o UserKnownHostsFile='.ssh/known_hosts' -o StrictHostKeyChecking=accept-new -i 'private-key'", ); expect(mockGitInstance.env).toHaveBeenCalledWith('GIT_TERMINAL_PROMPT', '0'); }); @@ -614,11 +624,11 @@ describe('SourceControlGitService', () => { // Assert - verify paths with spaces are properly quoted expect(mockGitInstance.env).toHaveBeenCalledWith( 'GIT_SSH_COMMAND', - expect.stringContaining('"C:/Users/Test User/.n8n/ssh_private_key_temp"'), // Quoted path with spaces + expect.stringContaining("'C:/Users/Test User/.n8n/ssh_private_key_temp'"), // Quoted path with spaces ); expect(mockGitInstance.env).toHaveBeenCalledWith( 'GIT_SSH_COMMAND', - expect.stringContaining('"C:/Users/Test User/.n8n/.ssh/known_hosts"'), // Quoted known_hosts path + expect.stringContaining("'C:/Users/Test User/.n8n/.ssh/known_hosts'"), // Quoted known_hosts path ); expect(mockGitInstance.env).toHaveBeenCalledWith( 'GIT_SSH_COMMAND', @@ -630,7 +640,7 @@ describe('SourceControlGitService', () => { ); }); - it('should escape double quotes in paths to prevent command injection', async () => { + it('should single-quote paths to prevent command injection', async () => { // Arrange const mockPreferencesService = mock(); const pathWithQuotes = 'C:/Users/Test"User/.n8n/ssh_private_key_temp'; @@ -655,14 +665,56 @@ describe('SourceControlGitService', () => { // Act await gitService.setGitCommand('/git/folder', sshFolder); - // Assert - verify the SSH command was properly escaped + // Assert - the double quote is kept literal inside single quotes, so it + // cannot terminate the argument and inject a command. expect(mockGitInstance.env).toHaveBeenCalledWith( 'GIT_SSH_COMMAND', - expect.stringContaining('Test\\"User'), // Escaped quote + expect.stringContaining("-i 'C:/Users/Test\"User/.n8n/ssh_private_key_temp'"), ); expect(mockGitInstance.env).toHaveBeenCalledWith( 'GIT_SSH_COMMAND', - expect.not.stringContaining('Test"User'), // No unescaped quote in final command + expect.stringContaining( + "-o UserKnownHostsFile='C:/Users/Test\"User/.n8n/.ssh/known_hosts'", + ), + ); + }); + + it('should escape single quotes in paths to prevent command injection', async () => { + // Arrange - a path containing a single quote (e.g. a Windows user + // folder like "John's") would otherwise terminate the quoted argument. + const mockPreferencesService = mock(); + const pathWithSingleQuote = "C:/Users/John's/.n8n/ssh_private_key_temp"; + const sshFolder = "C:/Users/John's/.n8n/.ssh"; + + mockPreferencesService.getPrivateKeyPath.mockResolvedValue(pathWithSingleQuote); + mockPreferencesService.getPreferences.mockReturnValue({ + connectionType: 'ssh', + connected: true, + repositoryUrl: 'git@github.com:user/repo.git', + branchName: 'main', + branchReadOnly: false, + branchColor: '#5296D6', + initRepo: false, + keyGeneratorType: 'ed25519', + }); + + const gitService = new SourceControlGitService(mock(), mock(), mockPreferencesService); + + // Act + await gitService.setGitCommand('/git/folder', sshFolder); + + // Assert - the single quote is emitted as the POSIX '\'' sequence + // (close-quote, escaped quote, reopen-quote), keeping it literal so + // it cannot break out of the argument and inject a command. + expect(mockGitInstance.env).toHaveBeenCalledWith( + 'GIT_SSH_COMMAND', + expect.stringContaining("-i 'C:/Users/John'\"'\"'s/.n8n/ssh_private_key_temp'"), + ); + expect(mockGitInstance.env).toHaveBeenCalledWith( + 'GIT_SSH_COMMAND', + expect.stringContaining( + "-o UserKnownHostsFile='C:/Users/John'\"'\"'s/.n8n/.ssh/known_hosts'", + ), ); }); }); diff --git a/packages/cli/src/modules/source-control.ee/source-control-git.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-git.service.ee.ts index 3946b513f25..c0ef0737121 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-git.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-git.service.ee.ts @@ -1,5 +1,4 @@ import { Logger } from '@n8n/backend-common'; -import { resolveProxyUrl } from '@n8n/backend-network'; import type { User } from '@n8n/db'; import { Service } from '@n8n/di'; import { execSync } from 'child_process'; @@ -16,6 +15,10 @@ import type { StatusResult, } from 'simple-git'; +import { + buildHttpsGitConfig, + buildSshCommand, +} from '@/modules/git-connections.ee/git-connections-git.utils'; import { OwnershipService } from '@/services/ownership.service'; import { @@ -130,47 +133,20 @@ export class SourceControlGitService { if (preferences.connectionType === 'https') { const credentials = await this.sourceControlPreferencesService.getDecryptedHttpsCredentials(); - const escapeShellArg = (arg: string) => `'${arg.replace(/'/g, "'\"'\"'")}'`; - const credentialScript = `!f() { echo username=${escapeShellArg(credentials.username)}; echo password=${escapeShellArg(credentials.password)}; }; f`; - + const config = buildHttpsGitConfig(preferences.repositoryUrl, credentials); const httpsGitOptions = { ...this.gitOptions, - config: [ - 'credential.helper=' + credentialScript, - // ensures that the credentials are only used for the configured repositoryUrl of the environment - 'credential.useHttpPath=true', - ], + config, unsafe: { allowUnsafeCredentialHelper: true }, }; - // Add proxy configuration if proxy environment variables are set - const repositoryUrl = preferences.repositoryUrl; - const proxyUrl = resolveProxyUrl(repositoryUrl); - if (proxyUrl) { - // Git uses http.proxy for both HTTP and HTTPS URLs - this.logger.debug('Proxy configuration added', { proxyUrl }); - httpsGitOptions.config.push(`http.proxy=${proxyUrl}`); - } - this.git = simpleGit(httpsGitOptions).env('GIT_TERMINAL_PROMPT', '0'); } else if (preferences.connectionType === 'ssh') { const privateKeyPath = await this.sourceControlPreferencesService.getPrivateKeyPath(); - const sshKnownHosts = path.join(sshFolder, 'known_hosts'); - - // Convert paths to POSIX format for SSH command (works cross-platform) - // Use regex to handle both Windows (\) and POSIX (/) separators regardless of current platform - const normalizedPrivateKeyPath = privateKeyPath.split(/[/\\]/).join('/'); - const normalizedKnownHostsPath = sshKnownHosts.split(/[/\\]/).join('/'); - - // Escape double quotes to prevent command injection - const escapedPrivateKeyPath = normalizedPrivateKeyPath.replace(/"/g, '\\"'); - const escapedKnownHostsPath = normalizedKnownHostsPath.replace(/"/g, '\\"'); - - // Quote paths to handle spaces and special characters - // Use StrictHostKeyChecking=accept-new to protect against MITM attacks: - // - First connection: accepts and saves host key to known_hosts - // - Subsequent connections: verifies against saved key - const sshCommand = `ssh -o UserKnownHostsFile="${escapedKnownHostsPath}" -o StrictHostKeyChecking=accept-new -i "${escapedPrivateKeyPath}"`; + const sshCommand = buildSshCommand({ + privateKeyPath, + knownHostsPath: path.join(sshFolder, 'known_hosts'), + }); // Allow GIT_SSH_COMMAND so we can point SSH at n8n's own private key and known_hosts. // This is safe because the command is constructed internally above, not from user input. diff --git a/packages/cli/src/modules/source-control.ee/source-control-helper.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-helper.ee.ts index 4a75fbf32e6..6e3678db844 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-helper.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-helper.ee.ts @@ -2,7 +2,6 @@ import type { SourceControlledFile } from '@n8n/api-types'; import { isContainedWithin, Logger, safeJoinPath } from '@n8n/backend-common'; import type { TagEntity, WorkflowTagMapping } from '@n8n/db'; import { Container } from '@n8n/di'; -import { generateKeyPairSync } from 'crypto'; import { accessSync, constants as fsConstants, mkdirSync } from 'fs'; import chunk from 'lodash/chunk'; import isEqual from 'lodash/isEqual'; @@ -19,6 +18,7 @@ import { readFile as fsReadFile } from 'node:fs/promises'; import path from 'path'; import { License } from '@/license'; +import { generateSshKeyPair as generateGitSshKeyPair } from '@/modules/git-connections.ee/git-connections-git.utils'; import { containsExpression } from '@/utils'; import { @@ -286,47 +286,8 @@ export function isSourceControlLicensed() { return license.isSourceControlLicensed(); } -export async function generateSshKeyPair(keyType: KeyPairType) { - // sshpk is CommonJS (`export =`): under nodenext, a native dynamic import only - // hoists some named exports onto the namespace (parsePrivateKey is missed), so - // read the real module.exports off `.default`. - const { default: sshpk } = await import('sshpk'); - const keyPair: KeyPair = { - publicKey: '', - privateKey: '', - }; - let generatedKeyPair: KeyPair; - switch (keyType) { - case 'ed25519': - generatedKeyPair = generateKeyPairSync('ed25519', { - privateKeyEncoding: { format: 'pem', type: 'pkcs8' }, - publicKeyEncoding: { format: 'pem', type: 'spki' }, - }); - break; - case 'rsa': - generatedKeyPair = generateKeyPairSync('rsa', { - modulusLength: 4096, - publicKeyEncoding: { - type: 'spki', - format: 'pem', - }, - privateKeyEncoding: { - type: 'pkcs8', - format: 'pem', - }, - }); - break; - } - const keyPublic = sshpk.parseKey(generatedKeyPair.publicKey, 'pem'); - keyPublic.comment = SOURCE_CONTROL_GIT_KEY_COMMENT; - keyPair.publicKey = keyPublic.toString('ssh'); - const keyPrivate = sshpk.parsePrivateKey(generatedKeyPair.privateKey, 'pem'); - keyPrivate.comment = SOURCE_CONTROL_GIT_KEY_COMMENT; - keyPair.privateKey = keyPrivate.toString('ssh-private'); - return { - privateKey: keyPair.privateKey, - publicKey: keyPair.publicKey, - }; +export async function generateSshKeyPair(keyType: KeyPairType): Promise { + return await generateGitSshKeyPair(keyType, SOURCE_CONTROL_GIT_KEY_COMMENT); } export function getRepoType(repoUrl: string): 'github' | 'gitlab' | 'other' {