validate git repo urls and refs, require a secret for webhook triggers, tidy internal endpoint comments

This commit is contained in:
jarek committed 2026-07-31 10:10:56 +02:00
1 parent c18ee21a66
commit 0a9d4ad10f
10 files changed
+186 -91

No files matched your search

+1 -1
View File
@@ -214,7 +214,7 @@ export function isSafeRelPath(p: string): boolean {
}
/** Resolve relPath inside root; returns null when it would escape root. */
function containedPath(root: string, relPath: string): string | null {
export function containedPath(root: string, relPath: string): string | null {
if (!isSafeRelPath(relPath)) return null;
const abs = resolve(root, relPath);
if (abs !== root && abs.startsWith(root + sep)) return abs;
+50
View File
@@ -0,0 +1,50 @@
/**
* Deny-list validation for user-supplied git repo URLs and refs, kept in its OWN
* import-light module (no db/sqlite) so it is unit-testable. git.ts re-exports and
* calls these before any git subprocess.
*
* These reject inputs that git would execute as a command / read as a local file /
* parse as an OPTION. Every legitimate scheme (https, http, ssh, git, scp-like
* git@host:path) passes unchanged, so this is fully backward-compatible.
*/
import { resolve, isAbsolute, sep } from 'node:path';
export function assertSafeRepoUrl(url: string): void {
const u = (url || '').trim();
if (!u) throw new Error('Repository URL is required');
// A value starting with '-' is consumed by git as an option (e.g. --upload-pack=).
if (u.startsWith('-')) throw new Error('Invalid repository URL');
// git's ext::/fd:: transports execute a command; file:// reads local paths.
const lower = u.toLowerCase();
if (lower.startsWith('ext::') || lower.startsWith('fd::') || lower.startsWith('file://')) {
throw new Error('Unsupported repository URL transport');
}
// A bare local filesystem path (no scheme, no scp-like host:) is not a remote.
if (u.startsWith('/') || u.startsWith('./') || u.startsWith('../')) {
throw new Error('Repository URL must be a remote (https/ssh/git), not a local path');
}
}
/** A git ref (branch/tag) must not be parseable as an option. Empty is allowed
* (callers fall back to a default like HEAD). */
export function assertSafeGitRef(ref: string | null | undefined): void {
const r = (ref || '').trim();
if (r && r.startsWith('-')) throw new Error('Invalid branch/ref name');
}
/**
* Resolve a user-supplied relative path (composePath / envFilePath) INSIDE the clone
* dir, throwing if it would ESCAPE the clone. Uses resolve-then-containment (like a plain
* `join` + escape check): an in-repo `..` (e.g. `stacks/../compose.yml`) or a leading `./`
* still resolve, only a path that escapes the clone is refused. `label` names the field
* for the error message.
*/
export function repoFilePath(repoPath: string, userRel: string, label: string): string {
if (isAbsolute(userRel)) throw new Error(`${label} must be a relative path (got "${userRel}")`);
const abs = resolve(repoPath, userRel);
if (abs !== repoPath && !abs.startsWith(repoPath + sep)) {
throw new Error(`${label} must be a path inside the repository (got "${userRel}")`);
}
return abs;
}
+14 -6
View File
@@ -17,6 +17,7 @@ import {
import { deployStack, getStackDir } from './stacks';
import { sendEventNotification } from './notifications';
import { buildBasicAuthHeader } from './git-auth';
import { assertSafeRepoUrl, assertSafeGitRef, repoFilePath } from './git-url-safety';
import {
parseManifest,
serializeManifest,
@@ -307,6 +308,7 @@ function cleanupSshKey(credential: GitCredential | null): void {
}
function buildRepoUrl(url: string, credential: GitCredential | null): string {
assertSafeRepoUrl(url);
// Never embed credentials in the URL — they leak via /proc/<pid>/cmdline (see #1081).
// HTTPS credentials are injected via GIT_CONFIG_COUNT env vars in buildGitEnv().
// Strip any existing credentials from the URL for safety.
@@ -542,6 +544,7 @@ async function testRepositoryConnection(options: {
const { url, branch, credential } = options;
const env = await buildGitEnv(credential);
assertSafeGitRef(branch);
const repoUrl = buildRepoUrl(url, credential);
try {
@@ -675,6 +678,7 @@ export async function syncRepository(repoId: number): Promise<SyncResult> {
if (!existsSync(repoPath)) {
// Clone the repository (blobless clone - fetches all commits but blobs on-demand)
assertSafeGitRef(repo.branch);
const repoUrl = buildRepoUrl(repo.url, credential);
const result = await execGit(
@@ -714,7 +718,7 @@ export async function syncRepository(repoId: number): Promise<SyncResult> {
currentCommit = commitResult.stdout.substring(0, 7);
// Read the compose file
const composePath = join(repoPath, repo.composePath);
const composePath = repoFilePath(repoPath, repo.composePath, "Compose path");
if (!existsSync(composePath)) {
throw new Error(`Compose file not found: ${repo.composePath}`);
}
@@ -924,6 +928,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
}
console.log(`${logPrefix} Cloning repository...`);
assertSafeGitRef(repo.branch);
const repoUrl = buildRepoUrl(repo.url, credential);
const result = await execGit(
@@ -993,7 +998,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
console.log(`${logPrefix} Current commit:`, currentCommit);
// Read the compose file
const composePath = join(repoPath, gitStack.composePath);
const composePath = repoFilePath(repoPath, gitStack.composePath, "Compose path");
console.log(`${logPrefix} Reading compose file from:`, composePath);
if (!existsSync(composePath)) {
console.log(`${logPrefix} ERROR: Compose file not found at:`, composePath);
@@ -1035,7 +1040,7 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
let envFileContent: string | undefined;
let envFileName: string | undefined;
if (gitStack.envFilePath) {
const envFilePath = join(repoPath, gitStack.envFilePath);
const envFilePath = repoFilePath(repoPath, gitStack.envFilePath, "Env file path");
console.log(`${logPrefix} Looking for env file at:`, envFilePath);
if (existsSync(envFilePath)) {
try {
@@ -1280,6 +1285,7 @@ export async function testGitStack(stackId: number): Promise<TestResult> {
const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
const env = await buildGitEnv(credential);
assertSafeGitRef(repo.branch);
const repoUrl = buildRepoUrl(repo.url, credential);
try {
@@ -1388,6 +1394,7 @@ export async function deployGitStackWithProgress(
rmSync(repoPath, { recursive: true, force: true });
}
assertSafeGitRef(repo.branch);
const repoUrl = buildRepoUrl(repo.url, credential);
// Step 3: Fetching (blobless clone - fetches all commits but blobs on-demand)
@@ -1433,7 +1440,7 @@ export async function deployGitStackWithProgress(
// Step 4: Reading compose file
onProgress({ status: 'reading', message: `Reading ${gitStack.composePath}...`, step: 4, totalSteps });
const composePath = join(repoPath, gitStack.composePath);
const composePath = repoFilePath(repoPath, gitStack.composePath, "Compose path");
if (!existsSync(composePath)) {
throw new Error(`Compose file not found: ${gitStack.composePath}`);
}
@@ -1462,7 +1469,7 @@ export async function deployGitStackWithProgress(
// Read env file if configured (optional - don't fail if missing)
let envFileVars: Record<string, string> | undefined;
if (gitStack.envFilePath) {
const envFilePath = join(repoPath, gitStack.envFilePath);
const envFilePath = repoFilePath(repoPath, gitStack.envFilePath, "Env file path");
if (existsSync(envFilePath)) {
try {
const envContent = readFileSync(envFilePath, 'utf-8');
@@ -1527,7 +1534,7 @@ export async function deployGitStackWithProgress(
// Determine env filename relative to compose dir (same logic as syncGitStack)
let envFileName: string | undefined;
if (gitStack.envFilePath) {
const envFilePath = join(repoPath, gitStack.envFilePath);
const envFilePath = repoFilePath(repoPath, gitStack.envFilePath, "Env file path");
if (existsSync(envFilePath)) {
envFileName = relative(composeDir, envFilePath);
}
@@ -1789,6 +1796,7 @@ export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise<P
// Build git environment with credentials
// Cast credential to GitCredential type (only uses id, authType, sshPrivateKey)
const env = await buildGitEnv(credential as GitCredential | null);
assertSafeGitRef(branch);
const authenticatedUrl = buildRepoUrl(repoUrl, credential as GitCredential | null);
// Clone with depth 1 (shallow clone for speed)
+32
View File
@@ -0,0 +1,32 @@
import crypto from 'node:crypto';
/**
* Verify a git webhook signature against the configured secret.
*
* Two provider families share these two wire formats:
* - GitHub / Gitea / Forgejo send `X-Hub-Signature-256: sha256=<hmac>` where the
* hmac is HMAC-SHA256 of the raw request body keyed by the secret.
* - GitLab sends `X-Gitlab-Token: <secret>` as a plain token compared for equality.
*
* The caller passes whichever header value is present as `signature`.
*/
export function verifyWebhookSignature(payload: string, signature: string | null, secret: string): boolean {
if (!signature) return false;
if (signature.startsWith('sha256=')) {
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const sigBuf = Buffer.from(signature);
const expectedBuf = Buffer.from(expectedSignature);
if (sigBuf.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(sigBuf, expectedBuf);
}
// GitLab token: exact match, constant-time.
const sigBuf = Buffer.from(signature);
const secretBuf = Buffer.from(secret);
if (sigBuf.length !== secretBuf.length) return false;
return crypto.timingSafeEqual(sigBuf, secretBuf);
}