feat(core): Add Git connections module and service (no-changelog) (#36270)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Irénée 2026-08-19 13:46:51 +00:00 committed by GitHub
parent 3741de521b
commit 17567043bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1298 additions and 90 deletions

View File

@ -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);
});
});

View File

@ -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<typeof gitConnectionTypeSchema>;
export type GitKeyGeneratorType = z.infer<typeof gitKeyGeneratorTypeSchema>;
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(),
}) {}

View File

@ -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';

View File

@ -14,6 +14,7 @@ export const MODULE_NAMES = [
'provisioning',
'breaking-changes',
'source-control',
'git-connections',
'dynamic-credentials',
'chat-hub',
'sso-oidc',

View File

@ -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',

View File

@ -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',

View File

@ -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,

View File

@ -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();
});
});
});
});

View File

@ -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');
});
});
});

View File

@ -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<GitConnectionRepository>();
const gitService = mock<GitConnectionsGitService>();
const cipher = mock<Cipher>();
const instanceSettings = mock<InstanceSettings>({ 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();
});
});
});

View File

@ -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;

View File

@ -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 })

View File

@ -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<GitConnection> {
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 };
}
}

View File

@ -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::<cmd>`, 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<T>(
{
connection,
credentials,
baseDir,
}: {
connection: GitConnection;
credentials: PlainCredentials;
baseDir: string;
},
operation: (git: SimpleGit) => Promise<T>,
) {
await mkdir(baseDir, { recursive: true });
const options: Partial<SimpleGitOptions> = {
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 });
}
}
}

View File

@ -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'),
};
}

View File

@ -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];
}
}

View File

@ -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;
}
}

View File

@ -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<SourceControlPreferencesService>();
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<SourceControlPreferencesService>();
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'",
),
);
});
});

View File

@ -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.

View File

@ -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<KeyPair> {
return await generateGitSshKeyPair(keyType, SOURCE_CONTROL_GIT_KEY_COMMENT);
}
export function getRepoType(repoUrl: string): 'github' | 'gitlab' | 'other' {