feat: add branch selection to Git Stack modal with branches API endpoint (#1343)

* feat: add branch selection to Git Stack modal with branches API endpoint

- Add branch dropdown in create mode (fetches branches from new API endpoint)
- Add branch field in edit mode for existing Git stacks
- Remove redundant branch badge next to repository URL in edit mode
- Add POST /api/git/branches endpoint to list available branches
- Export buildGitEnv, buildRepoUrl, execGit from git.ts for reuse

* cla: recheck with correct author email

* fix: harden git branches endpoint and fix credential resolution

* feat(git-stacks): per-stack branch override

Add a per-stack 'branch' column to git_stacks so each stack can deploy
from a branch different from its repository's default.

- Migration 0012 (drizzle + drizzle-pg): add nullable 'branch' column
- resolveStackBranch() in git.ts: effective branch = per-stack override,
  else repository default; wired into sync/deploy/test paths
- db.ts: persist/read branch; GitStackData.branch is string | null
- API routes (preview-env, stacks create/update): honor explicit branch
  override with partial-update semantics (null/empty clears the override)
- UI: GitStackModal shows a 'Repository default' sentinel + per-stack
  selector; GitSourceBadge displays effective branch with a '(per-stack)'
  marker; stacks page passes through the per-stack branch
- tests/git-stack-branch.test.ts: unit coverage for resolveStackBranch
  and effective-branch display semantics

* feat(git-branches): security hardening per PR #1343 maintainer review

Adopts the three security recommendations from the maintainer review on
the git-branch-stack feature:

1. SSRF guard (assertSafeBranchTarget)
   New src/lib/server/git-branch-lookup.ts rejects repository URLs that
   point at private/loopback/link-local addresses before any git
   subprocess is spawned. Covers IPv4 (127/8, 169.254/16, 10/8,
   172.16/12, 192.168/16, 100.64/10, 0/8), IPv6 (::1, fe80::/10,
   fc00::/7, ::), IPv4-mapped IPv6 (::ffff:a.b.c.d), and a 25-entry
   hostname denylist (localhost, metadata.google.internal,
   host.docker.internal, etc.). Raw-URL parse only — no DNS resolution,
   avoiding TOCTOU/rebinding. Runs on BOTH the url and repositoryId
   paths (a stored repo's URL could also point internal).

2. Credential-exfiltration guard (assertCredentialUrlPair)
   A raw url paired with a stored credentialId is only allowed when the
   credential is plausibly for that host. SSH and none credentials are
   always allowed (no secret to leak). Password credentials: allowed
   when the username equals the URL host, or when the credential name
   (or its hyphen/underscore-separated segments) equals the host or its
   first label (e.g. "github-pat" → github.com). Inference-based — no
   new DB column. The repositoryId path is not subject to this guard
   (the pairing is the user's own stored config).

3. Timeout on git subprocesses
   execGit now accepts a timeoutMs parameter (default GIT_TIMEOUT_MS =
   20s, env-overridable). On timeout the process is killed with SIGKILL
   and the caller receives { timedOut: true, code: 124 }. Applied to all
   execGit call sites (clone, pull, fetch, ls-remote, etc.) so a dead
   or black-holed remote cannot stall a request indefinitely.

OpenAPI: @openapi JSDoc block added to the branches endpoint; spec
regenerated via the upstream generator (359/359 coverage, all 6 gates
pass). /api/git/branches POST entry added to openapi.generated.json.

Svelte5: fix-svelte-optional-params.sh applied to 7 .svelte files
(x?: T → x: T = undefined) to work around esbuild's rejection of
TS-only optional-parameter syntax in lang="ts" script tags.

Tests: 59 tests in tests/git-branches.test.ts covering SSRF (blocked
IPs, boundary cases), credential affinity (allowed/rejected cases),
endpoint pipeline ordering, and parseRepoHost edge cases.

Rebased onto upstream/main (a485c85) — zero conflicts; GitStackModal.svelte
had not drifted since the branch was created.

* fix(git): close SSRF/RCE/exfil gaps across all git URL+credential endpoints (PR #1343 re-review)

1. SSRF: replace hand-rolled assertSafeBranchTarget with shared URL()-based
   isSafeNotificationUrl — blocks decimal/octal/hex/short-form IPv4 and
   v4-mapped IPv6 encodings (all six maintainer bypass payloads now blocked).
   ALL schemes (http/https/ssh/git/scp-like) now judge the CANONICALIZED
   host through that single check: non-http(s) schemes can't be passed to
   URL() as-is, so the parsed host (port-stripped, IPv6 bracketed only
   when it contains a colon) is run through isSafeNotificationUrl under
   an https:// scheme. Closes the ssh/git encoded-IP bypass (decimal
   2130706433, hex 0x7f000001, short-form 127.1 over ssh://, git:// and
   scp-like all resolve to 127.0.0.1 and are now blocked).
2. Credential affinity: drop the dead second rule — with rule (i) being
   username === host, the single-label variant was identical and unreachable.
3. listRemoteBranches + testRepositoryConfig call assertSafeRepoUrl explicitly
   so the ext::/file:: transport denylist holds on every path into a git
   subprocess, not only via buildRepoUrl.
4. preview-env: assertSafeRepoTarget (SSRF+RCE) + repoFilePath containment for
   composePath/envFilePath (path traversal) + assertCredentialHostMatch
   (exfil).
5. repositories/test: assertSafeRepoTarget + assertCredentialHostMatch.

Tests: 134 across 3 files (git-branches, preview-env-security,
repositories-test-security) — lock the six http(s) encoding bypasses, the
four ssh/git encoded-IP payloads at unit and endpoint level, the subdomain
attack, single-label username, path traversal, and the endpoint pipelines.

Verified safe (no change): backup/destinations/test, registries/test,
environments/test, git/repositories CRUD, git/stacks, git/repositories/[id]/test.

* fix(git): address branch stack review feedback

- scope remote branch lookup timeout to ls-remote only
- require git:edit permission for preview-env
- clarify SSRF policy while preserving private LAN Git hosts
- support free-text branch selection for new repositories
- share production branch resolution logic with tests and UI
- add testable preview-env request pipeline
This commit is contained in:
Bartosz S. authored and GitHub committed 2026-08-22 10:20:43 +02:00
1 parent 0885e8245f
commit 28bb291c32
29 files changed
+38984 -106

No files matched your search

+17
View File
@@ -2295,6 +2295,7 @@ export interface GitStackData {
environmentId: number | null;
repositoryId: number;
composePath: string;
branch: string | null; // Per-stack branch override; null = use repository default
envFilePath: string | null;
autoUpdate: boolean;
autoUpdateSchedule: 'daily' | 'weekly' | 'custom';
@@ -2333,6 +2334,7 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId,
repositoryId: gitStacks.repositoryId,
branch: gitStacks.branch,
composePath: gitStacks.composePath,
envFilePath: gitStacks.envFilePath,
autoUpdate: gitStacks.autoUpdate,
@@ -2366,6 +2368,7 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId,
repositoryId: gitStacks.repositoryId,
branch: gitStacks.branch,
composePath: gitStacks.composePath,
envFilePath: gitStacks.envFilePath,
autoUpdate: gitStacks.autoUpdate,
@@ -2399,6 +2402,7 @@ export async function getGitStacks(environmentId?: number): Promise<GitStackWith
stackName: row.stackName,
environmentId: row.environmentId,
repositoryId: row.repositoryId,
branch: row.branch ?? null,
composePath: row.composePath,
envFilePath: row.envFilePath,
autoUpdate: row.autoUpdate,
@@ -2434,6 +2438,7 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId,
repositoryId: gitStacks.repositoryId,
branch: gitStacks.branch,
composePath: gitStacks.composePath,
envFilePath: gitStacks.envFilePath,
autoUpdate: gitStacks.autoUpdate,
@@ -2467,6 +2472,7 @@ export async function getGitStacksForEnvironmentOnly(environmentId: number): Pro
stackName: row.stackName,
environmentId: row.environmentId,
repositoryId: row.repositoryId,
branch: row.branch ?? null,
composePath: row.composePath,
envFilePath: row.envFilePath,
autoUpdate: row.autoUpdate,
@@ -2501,6 +2507,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId,
repositoryId: gitStacks.repositoryId,
branch: gitStacks.branch,
composePath: gitStacks.composePath,
envFilePath: gitStacks.envFilePath,
autoUpdate: gitStacks.autoUpdate,
@@ -2536,6 +2543,7 @@ export async function getGitStack(id: number): Promise<GitStackWithRepo | null>
stackName: row.stackName,
environmentId: row.environmentId,
repositoryId: row.repositoryId,
branch: row.branch ?? null,
composePath: row.composePath,
envFilePath: row.envFilePath,
autoUpdate: row.autoUpdate,
@@ -2571,6 +2579,7 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId,
repositoryId: gitStacks.repositoryId,
branch: gitStacks.branch,
composePath: gitStacks.composePath,
envFilePath: gitStacks.envFilePath,
autoUpdate: gitStacks.autoUpdate,
@@ -2610,6 +2619,7 @@ export async function getGitStackByName(stackName: string, environmentId?: numbe
stackName: row.stackName,
environmentId: row.environmentId,
repositoryId: row.repositoryId,
branch: row.branch ?? null,
composePath: row.composePath,
envFilePath: row.envFilePath,
autoUpdate: row.autoUpdate,
@@ -2644,6 +2654,7 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
stackName: gitStacks.stackName,
environmentId: gitStacks.environmentId,
repositoryId: gitStacks.repositoryId,
branch: gitStacks.branch,
composePath: gitStacks.composePath,
envFilePath: gitStacks.envFilePath,
autoUpdate: gitStacks.autoUpdate,
@@ -2678,6 +2689,7 @@ export async function getGitStackByWebhookSecret(secret: string): Promise<GitSta
stackName: row.stackName,
environmentId: row.environmentId,
repositoryId: row.repositoryId,
branch: row.branch ?? null,
composePath: row.composePath,
envFilePath: row.envFilePath,
autoUpdate: row.autoUpdate,
@@ -2710,6 +2722,7 @@ export async function createGitStack(data: {
stackName: string;
environmentId?: number | null;
repositoryId: number;
branch?: string | null;
composePath?: string;
envFilePath?: string | null;
autoUpdate?: boolean;
@@ -2727,6 +2740,7 @@ export async function createGitStack(data: {
stackName: data.stackName,
environmentId: data.environmentId ?? null,
repositoryId: data.repositoryId,
branch: data.branch || null,
composePath: data.composePath || 'compose.yaml',
envFilePath: data.envFilePath || null,
contextDir: data.contextDir || null,
@@ -2749,6 +2763,7 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
if (data.stackName !== undefined) updateData.stackName = data.stackName;
if (data.repositoryId !== undefined) updateData.repositoryId = data.repositoryId;
if (data.composePath !== undefined) updateData.composePath = data.composePath;
if (data.branch !== undefined) updateData.branch = data.branch || null;
if (data.envFilePath !== undefined) updateData.envFilePath = data.envFilePath;
if (data.autoUpdate !== undefined) updateData.autoUpdate = data.autoUpdate;
if (data.autoUpdateSchedule !== undefined) updateData.autoUpdateSchedule = data.autoUpdateSchedule;
@@ -2821,6 +2836,7 @@ export async function getEnabledAutoUpdateGitStacks(): Promise<GitStackWithRepo[
stackName: row.stackName,
environmentId: row.environmentId,
repositoryId: row.repositoryId,
branch: row.branch ?? null,
composePath: row.composePath,
envFilePath: row.envFilePath,
autoUpdate: row.autoUpdate,
@@ -2886,6 +2902,7 @@ export async function getAllAutoUpdateGitStacks(): Promise<GitStackWithRepo[]> {
stackName: row.stackName,
environmentId: row.environmentId,
repositoryId: row.repositoryId,
branch: row.branch ?? null,
composePath: row.composePath,
autoUpdate: row.autoUpdate,
autoUpdateSchedule: row.autoUpdateSchedule,
+1
View File
@@ -321,6 +321,7 @@ export const gitStacks = sqliteTable('git_stacks', {
stackName: text('stack_name').notNull(),
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }),
branch: text('branch'), // Per-stack branch override; null = use repository default
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod")
autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false),
+1
View File
@@ -324,6 +324,7 @@ export const gitStacks = pgTable('git_stacks', {
stackName: text('stack_name').notNull(),
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }),
branch: text('branch'), // Per-stack branch override; null = use repository default
composePath: text('compose_path').default('docker-compose.yml'), // Reverted to original value (#1110)
envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod")
autoUpdate: boolean('auto_update').default(false),
+178
View File
@@ -0,0 +1,178 @@
/**
* Security guards for user-supplied git repository URLs on the branch-lookup
* and env-preview flows (POST /api/git/branches, POST /api/git/preview-env).
*
* SECURITY CONTEXT (PR #1343 maintainer review, rounds 1+2):
*
* 1. SSRF — the URL is handed to a git subprocess whose libcurl resolves
* EVERY legal IP encoding (decimal 2130706433, octal 0177.0.0.1, short
* form 127.1, hex 0x7f000001, un-compressed v4-mapped IPv6). Hand-rolled
* IP normalization is a rabbit hole — the SSRF check therefore delegates
* to isSafeNotificationUrl (src/lib/server/url-safety.ts), which is
* URL()-based, canonicalizes all of the encodings above, and is the same
* battle-tested guard our secret-provider hosts use. We only prepend a
* scheme so that ssh:// and scp-like URLs (which are valid git remotes
* but not valid URL() inputs) can be canonicalized.
*
* 2. Credential affinity — a raw url + stored credentialId is ONLY allowed
* when the credential's username is plausibly FOR that host (username ==
* host, or a single-label credential username equal to the host). No
* name-segment inference, no first-label matching: a single-label
* credential username ("github", "admin", "git") plus an attacker
* subdomain (github.evil.com) would otherwise deliver the decrypted
* secret to the attacker. The repositoryId path is not subject to this
* guard — the pairing is the user's own stored repository config.
*
* 3. Transport denylist — assertSafeRepoUrl (git-url-safety.ts) rejects the
* ext::/fd::/file:: transports and local paths. listRemoteBranches calls
* it explicitly so the denylist holds on every path into a git
* subprocess, not only via buildRepoUrl.
*
* Import-light (no db/sqlite) so it stays unit-testable.
*/
import { isSafeNotificationUrl } from './url-safety';
import { assertSafeRepoUrl } from './git-url-safety';
import type { GitCredential } from './db';
/**
* Parse the host out of a git repository URL.
*
* Supported forms:
* https://host[:port]/path, http://... → host (port stripped)
* ssh://[user@]host[:port]/path → host
* git://host[:port]/path → host
* [user@]host:path (scp-like) → host
* [fe80::1] / [fe80::1]:port → fe80::1
*
* Returns null when no host can be determined (bare path — already rejected
* by assertSafeRepoUrl upstream, or a scheme we don't accept).
*/
export function parseRepoHost(url: string): string | null {
const u = (url || '').trim();
if (!u) return null;
const schemeMatch = u.match(/^[a-z][a-z0-9+.-]*:/i);
if (schemeMatch) {
const scheme = schemeMatch[0].slice(0, -1).toLowerCase();
if (!['http', 'https', 'ssh', 'git'].includes(scheme)) return null;
const rest = u.slice(schemeMatch[0].length).replace(/^\/\//, '');
// IPv6 in brackets: [fe80::1] or [fe80::1]:8080 — strip brackets.
const bracket = rest.match(/^\[([0-9a-fA-F:.]+)\]/);
if (bracket) return bracket[1].toLowerCase();
const hostPart = rest.split('/')[0] || '';
const atIdx = hostPart.lastIndexOf('@');
const hostWithPort = atIdx >= 0 ? hostPart.slice(atIdx + 1) : hostPart;
const host = hostWithPort.split(':')[0];
return host ? host.toLowerCase() : null;
}
// No scheme. IPv6 in brackets: [fe80::1], possibly with a port.
const bracket = u.match(/^\[([0-9a-fA-F:.]+)\](?::\d+)?$/);
if (bracket) return bracket[1].toLowerCase();
// scp-like: [user@]host:path
const colonIdx = u.indexOf(':');
if (colonIdx <= 0) return null; // no scheme, no scp colon → no host
const hostPart = u.slice(0, colonIdx);
const atIdx = hostPart.lastIndexOf('@');
const host = atIdx >= 0 ? hostPart.slice(atIdx + 1) : hostPart;
return host ? host.toLowerCase() : null;
}
// ---------------------------------------------------------------------------
// Shared host safety check — ONE canonicalization path for every scheme.
//
// isSafeNotificationUrl is URL()-based: new URL() canonicalizes every
// legal IP encoding (decimal 2130706433, hex 0x7f000001, octal, short-form
// 127.1, v4-mapped IPv6) BEFORE the range check, and its hostname
// denylist covers internal hostnames. For non-http(s) git schemes we can't
// hand the original URL to it (URL() rejects ssh:// / git:// and scp-like
// syntax), so we canonicalize the PARSED host (port already stripped by
// parseRepoHost, brackets stripped for IPv6) under an https:// scheme and
// run it through the same battle-tested check. This closes the encoded-IP
// bypass on the ssh/git path: ipCategory matches only a plain dotted-quad,
// so decimal/hex/short-form hosts sailed through it (2026-08-21
// maintainer re-review).
// ---------------------------------------------------------------------------
function assertHostSafeFromParse(parsedHost: string): void {
// IPv6 literals need brackets for URL() to accept them — but ONLY if the
// host actually contains a colon. Wrapping a plain IPv4 literal
// (10.0.0.5) in brackets produces an invalid URL() input, which would
// be rejected as "not a valid URL" and block every LAN IP.
const host = parsedHost.includes(':') ? `[${parsedHost}]` : parsedHost;
const check = isSafeNotificationUrl(`https://${host}/`);
if (!check.ok) {
throw new Error(
`Repository URL host is not allowed (loopback, cloud-metadata or other reserved/dangerous target): ${check.reason}`
);
}
}
/**
* Guard 1: reject a git repository URL that points at a dangerous target
* (SSRF defense), or a hostname that resolves to internal infrastructure.
*
* Policy (shared isSafeNotificationUrl / dangerousHostReason, src/lib/server/
* url-safety.ts): loopback (127.x/::1), cloud-metadata / link-local
* (169.254.169.254), multicast/reserved and localhost are REJECTED.
* Ordinary private-LAN ranges (10.x / 192.168.x / 172.16-31.x) are INTENTIONALLY
* ALLOWED so self-hosted Git servers on the RFC1918 LAN keep working — this
* is Dockhand's self-hosted-service policy (same as the notification
* receivers and backup repos). Do NOT "fix" this into the strict
* isSafeWebhookUrl / privateIpReason policy (that blocks all private) —
* doing so would break self-hosted Git on a LAN.
*
* Every scheme is judged on the CANONICALIZED host through
* isSafeNotificationUrl (see assertHostSafeFromParse): http(s) via the
* original URL, ssh/git/scp-like via the parsed host. localhost is blocked
* explicitly so ssh://localhost and scp-like localhost stay covered by one
* code path.
*/
export function assertSafeRepoTarget(url: string): void {
assertSafeRepoUrl(url); // transport denylist (ext::/fd::/file::/local paths)
const host = parseRepoHost(url);
if (!host) {
throw new Error('Invalid repository URL for branch lookup');
}
if (host === 'localhost' || host.endsWith('.localhost')) {
throw new Error('Repository URL host is not allowed for branch lookup');
}
assertHostSafeFromParse(host);
}
/**
* Guard 2: a raw URL may only be paired with a stored password credential
* when the credential is plausibly FOR that host (credential-exfiltration
* defense).
*
* Rules:
* - `ssh` / `none` credentials: ALWAYS allowed (SSH keys are validated by
* the remote server; there is no secret to leak for `none`).
* - `password` credentials:
* the credential's username equals the URL host (PAT-style: the
* username field holds the account name that the host expects).
*
* Deliberately NO name-segment inference and NO first-label matching: a
* single-label credential username ("github", "admin", "git") would match
* ANY attacker subdomain (github.evil.com / admin.evil.com) and deliver
* the decrypted secret there. Hostnames are compared exactly (case-insensitive,
* with a port stripped) — a single-label username only matches a single-label
* host, which cannot be an attacker-chosen subdomain of another host.
*/
export function assertCredentialHostMatch(url: string, credential: GitCredential): void {
if (!credential) return;
if (credential.authType === 'ssh' || credential.authType === 'none') return;
const host = parseRepoHost(url);
if (!host) {
throw new Error('Invalid repository URL for branch lookup');
}
const username = (credential.username || '').trim().toLowerCase();
// Username must match the full host exactly (PAT-style). A username
// containing a dot matches only a dot-containing host — never a
// subdomain of it.
if (username && username === host) return;
throw new Error(
'Stored credential does not match the repository URL host'
);
}
+200 -21
View File
@@ -19,6 +19,8 @@ import { deployStack, getStackDir } from './stacks';
import { sendEventNotification } from './notifications';
import { buildBasicAuthHeader } from './git-auth';
import { assertSafeRepoUrl, assertSafeGitRef, repoFilePath } from './git-url-safety';
import { assertSafeRepoTarget } from './git-branch-lookup';
import { resolveStackBranch } from '../git-stack-branch';
import {
parseManifest,
serializeManifest,
@@ -325,20 +327,89 @@ function buildRepoUrl(url: string, credential: GitCredential | null): string {
return url;
}
async function execGit(args: string[], cwd: string, env: GitEnv): Promise<{ stdout: string; stderr: string; code: number }> {
try {
/**
* Hard timeout (ms) for the remote-branch lookup (git ls-remote) ONLY.
* The lookup is the single git call whose target URL can be driven by an
* attacker-chosen URL (POST /api/git/branches, the new-repository branch
* picker), so an unbounded ls-remote is a resource-exhaustion vector.
* NOTE: this constant is deliberately NOT applied to ordinary deploy/sync
* operations (clone / pull / fetch / rev-parse) — those target the
* operator's own repository over a controlled URL, and a legitimate
* large-repo clone or slow WAN fetch can exceed this limit, so they stay
* UNBOUNDED (execGit without an explicit timeout). */
export const GIT_TIMEOUT_MS = Number(process.env.GIT_TIMEOUT_MS) || 20000;
/**
* Run a git command.
*
* Timeouts are OPT-IN: the default is UNBOUNDED. Ordinary deploy/sync
* operations (clone, pull, fetch, rev-parse) are unbounded because their
* target is the operator's own repository and a slow network or large repo
* is a legitimate outcome — they must not be killed mid-flight. Callers
* that need a bounded operation (the remote-branch lookup, whose target
* URL is attacker-influenced) pass `timeoutMs` explicitly (see
* listRemoteBranches).
*/
function execGit(
args: string[],
cwd: string,
env: GitEnv,
timeoutMs?: number
): Promise<{ stdout: string; stderr: string; code: number; timedOut?: boolean }> {
return new Promise((resolve) => {
let settled = false;
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const proc = nodeSpawn('git', args, {
cwd,
env,
stdio: ['pipe', 'pipe', 'pipe']
});
const result = await collectProcess(proc);
const finish = (
partial: { stdout: string; stderr: string; code: number; timedOut?: boolean }
) => {
if (settled) return;
settled = true;
// Only clear a timer if one was actually created — the default
// (no timeout) never creates one.
if (timer) clearTimeout(timer);
if (partial.timedOut) {
// The process is still running — kill it so the git subprocess
// (and any child ssh) cannot keep running after we give up.
proc.kill('SIGKILL');
}
resolve(partial);
};
return { stdout: result.stdout.trim(), stderr: result.stderr.trim(), code: result.exitCode };
} catch (err: any) {
return { stdout: '', stderr: err.message, code: 1 };
}
// Only arm a timer when a valid timeout was explicitly provided —
// undefined (or non-finite / non-positive) means "run unbounded".
const timer: ReturnType<typeof setTimeout> | undefined =
timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0
? setTimeout(() => {
finish({
stdout: Buffer.concat(stdoutChunks).toString(),
stderr: Buffer.concat(stderrChunks).toString(),
code: 124,
timedOut: true
});
}, timeoutMs)
: undefined;
proc.stdout?.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
proc.stderr?.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
proc.on('error', (err: any) => {
finish({ stdout: '', stderr: err.message, code: 1 });
});
proc.on('close', (code) => {
finish({
stdout: Buffer.concat(stdoutChunks).toString(),
stderr: Buffer.concat(stderrChunks).toString(),
code: code ?? 1
});
});
});
}
/**
@@ -641,6 +712,11 @@ export async function testRepositoryConfig(options: {
return { success: false, error: 'Repository URL is required' };
}
// Transport denylist (ext::/fd::/file::/local paths) — applied here so the
// denylist holds on every path into a git subprocess, not only via
// buildRepoUrl (same pattern as listRemoteBranches).
assertSafeRepoUrl(url);
// Fetch credential from database if credentialId is provided
const credential = credentialId ? await getGitCredential(credentialId) : null;
if (credentialId && !credential) {
@@ -654,6 +730,74 @@ export async function testRepositoryConfig(options: {
});
}
/**
* List remote branches for a repository URL using git ls-remote.
* Resolves credentials from the database if a credentialId is provided.
* Bounded by a hard timeout (default GIT_TIMEOUT_MS, 20s) so a dead or
* black-holed host cannot stall the request — see execGit. This is the
* only git operation with a timeout: its target URL is the one that can be
* attacker-influenced, so an unbounded ls-remote would be a resource-
* exhaustion vector.
*
* The transport denylist (ext::/fd::/file::/local paths) is applied HERE,
* explicitly, rather than only via buildRepoUrl — a future parser change in
* buildRepoUrl must not be able to reopen command execution on this path.
*/
export async function listRemoteBranches(options: {
url: string;
credentialId?: number | null;
timeoutMs?: number;
}): Promise<{ branches: string[]; error?: string }> {
const { url, credentialId, timeoutMs } = options;
// The effective branch-lookup timeout: an explicitly provided (valid)
// override wins; otherwise the default GIT_TIMEOUT_MS. This is the ONLY
// bounded git operation — see execGit for why deploy/sync operations are
// left unbounded.
const effectiveTimeoutMs = timeoutMs ?? GIT_TIMEOUT_MS;
// Transport denylist — the same check buildRepoUrl applies, applied
// explicitly here so the denylist holds even if buildRepoUrl changes.
assertSafeRepoUrl(url);
// Fetch credential from database if credentialId is provided
const credential = credentialId ? await getGitCredential(credentialId) : null;
const env = await buildGitEnv(credential);
const repoUrl = buildRepoUrl(url, credential);
try {
const result = await execGit(
['ls-remote', '--heads', '--refs', repoUrl],
process.cwd(),
env,
effectiveTimeoutMs
);
if (result.timedOut) {
return { branches: [], error: `git ls-remote timed out after ${Math.round(effectiveTimeoutMs / 1000)}s` };
}
if (result.code !== 0) {
return { branches: [], error: cleanGitError(result.stderr) };
}
const branches = result.stdout.split('\n')
.filter(l => l.trim())
.map(l => {
const m = l.match(/refs\/heads\/(.+)$/);
return m ? m[1] : null;
})
.filter(Boolean) as string[];
return { branches };
} catch (error: any) {
return { branches: [], error: error.message };
} finally {
cleanupSshKey(credential);
}
}
export async function syncRepository(repoId: number): Promise<SyncResult> {
const repo = await getGitRepository(repoId);
if (!repo) {
@@ -872,6 +1016,13 @@ async function getPreviousCommit(repoPath: string, env: GitEnv): Promise<string
}
}
/**
* The effective branch a git stack deploys from is resolved by
* resolveStackBranch() (src/lib/git-stack-branch.ts) — a per-stack branch
* override (git_stacks.branch) wins; when it is unset, empty, or
* whitespace-only, the repository's default branch is used.
*/
export async function syncGitStack(stackId: number): Promise<SyncResult> {
const gitStack = await getGitStack(stackId);
if (!gitStack) {
@@ -901,8 +1052,14 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
return { success: false, error: 'Repository not found' };
}
// Per-stack branch override wins; unset means the repository default
const effectiveBranch = resolveStackBranch(gitStack, repo);
console.log(`${logPrefix} Repository URL:`, repo.url);
console.log(`${logPrefix} Repository branch:`, repo.branch);
if (effectiveBranch !== repo.branch) {
console.log(`${logPrefix} Stack branch override:`, effectiveBranch);
}
const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId);
@@ -928,11 +1085,11 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
}
console.log(`${logPrefix} Cloning repository...`);
assertSafeGitRef(repo.branch);
assertSafeGitRef(effectiveBranch);
const repoUrl = buildRepoUrl(repo.url, credential);
const result = await execGit(
['clone', '--filter=blob:none', '--branch', repo.branch, repoUrl, repoPath],
['clone', '--filter=blob:none', '--branch', effectiveBranch, repoUrl, repoPath],
process.cwd(),
env
);
@@ -1283,15 +1440,18 @@ export async function testGitStack(stackId: number): Promise<TestResult> {
return { success: false, error: 'Repository not found' };
}
// Per-stack branch override wins; unset means the repository default
const effectiveBranch = resolveStackBranch(gitStack, repo);
const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
const env = await buildGitEnv(credential);
assertSafeGitRef(repo.branch);
assertSafeGitRef(effectiveBranch);
const repoUrl = buildRepoUrl(repo.url, credential);
try {
// Use git ls-remote to test connection and get branch info
const result = await execGit(
['ls-remote', '--heads', '--refs', repoUrl, repo.branch],
['ls-remote', '--heads', '--refs', repoUrl, effectiveBranch],
process.cwd(),
env
);
@@ -1305,12 +1465,12 @@ export async function testGitStack(stackId: number): Promise<TestResult> {
// Parse the output to get commit hash
const lines = result.stdout.split('\n').filter(l => l.trim());
if (lines.length === 0) {
return { success: false, error: `Branch '${repo.branch}' not found in repository` };
return { success: false, error: `Branch '${effectiveBranch}' not found in repository` };
}
const match = lines[0].match(/^([a-f0-9]+)\s+refs\/heads\/(.+)$/);
const lastCommit = match ? match[1].substring(0, 7) : undefined;
const branch = match ? match[2] : repo.branch;
const branch = match ? match[2] : effectiveBranch;
cleanupSshKey(credential, env);
@@ -1368,6 +1528,9 @@ export async function deployGitStackWithProgress(
return { success: false, error: 'Repository not found' };
}
// Per-stack branch override wins; unset means the repository default
const effectiveBranch = resolveStackBranch(gitStack, repo);
const credential = repo.credentialId ? await getGitCredential(repo.credentialId) : null;
const repoPath = await getStackRepoPath(stackId, gitStack.stackName, gitStack.environmentId);
const env = await buildGitEnv(credential);
@@ -1394,13 +1557,13 @@ export async function deployGitStackWithProgress(
rmSync(repoPath, { recursive: true, force: true });
}
assertSafeGitRef(repo.branch);
assertSafeGitRef(effectiveBranch);
const repoUrl = buildRepoUrl(repo.url, credential);
// Step 3: Fetching (blobless clone - fetches all commits but blobs on-demand)
onProgress({ status: 'fetching', message: `Fetching branch ${repo.branch}...`, step: 3, totalSteps });
onProgress({ status: 'fetching', message: `Fetching branch ${effectiveBranch}...`, step: 3, totalSteps });
const cloneResult = await execGit(
['clone', '--filter=blob:none', '--branch', repo.branch, repoUrl, repoPath],
['clone', '--filter=blob:none', '--branch', effectiveBranch, repoUrl, repoPath],
process.cwd(),
env
);
@@ -1801,6 +1964,20 @@ export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise<P
// Build git environment with credentials
// Cast credential to GitCredential type (only uses id, authType, sshPrivateKey)
env = await buildGitEnv(credential as GitCredential | null);
// Security (PR #1343 maintainer review): the preview endpoint clones a
// USER-SUPPLIED URL and reads env files from it — the same SSRF / RCE /
// path-traversal surface as the branches endpoint. Apply the shared guards
// BEFORE any git subprocess or file read:
// 1. assertSafeRepoTarget — blocks loopback/link-local/metadata/reserved
// hosts (SSRF) and the ext::/file:: transport denylist (RCE). Ordinary
// private-LAN addresses are intentionally allowed (self-hosted Git).
// 2. repoFilePath — keeps composePath/envFilePath INSIDE the temp dir
// (path traversal). Without this, `envFilePath: '../../secrets/x'`
// reads a file outside the clone.
assertSafeRepoTarget(repoUrl);
const safeComposePath = repoFilePath(tempDir, composePath, 'Compose path');
const safeEnvFilePath = envFilePath ? repoFilePath(tempDir, envFilePath, 'Env file path') : null;
assertSafeGitRef(branch);
const authenticatedUrl = buildRepoUrl(repoUrl, credential as GitCredential | null);
@@ -1825,8 +2002,9 @@ export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise<P
console.log(`${logPrefix} Clone successful`);
// Determine the compose directory (where .env file should be)
const composeDir = dirname(composePath);
// Determine the compose directory (where .env file should be) — uses the
// VALIDATED path (already checked to be inside the temp dir).
const composeDir = dirname(safeComposePath);
const baseEnvPath = join(tempDir, composeDir, '.env');
const vars: Record<string, string> = {};
@@ -1846,9 +2024,10 @@ export async function previewRepoEnvFiles(options: PreviewEnvOptions): Promise<P
console.log(`${logPrefix} No .env file at ${baseEnvPath}`);
}
// Read additional env file if specified
if (envFilePath) {
const additionalEnvPath = join(tempDir, envFilePath);
// Read additional env file if specified — uses the VALIDATED path
// (already checked to be inside the temp dir).
if (safeEnvFilePath) {
const additionalEnvPath = safeEnvFilePath;
if (existsSync(additionalEnvPath)) {
console.log(`${logPrefix} Reading additional env file: ${additionalEnvPath}`);
const content = readFileSync(additionalEnvPath, 'utf-8');
+198
View File
@@ -0,0 +1,198 @@
/**
* Preview-env handler core — import-light, dependency-injected.
*
* The POST /api/git/preview-env route (src/routes/api/git/preview-env/
* +server.ts) is a thin wrapper that resolves the real dependencies
* (authorize, getGitRepository, getGitCredential, previewRepoEnvFiles, the
* SSRF guards) and calls this function. The route performs the HTTP/status
* mapping (json(..., { status: NNN })), so the OpenAPI generator (which
* statically scans the +server.ts handler body) sees all status codes.
*
* Keeping the business/security logic here — with all external dependencies
* injected — makes the EXACT production pipeline (permission gate, body
* parsing, SSRF/credential/path guards, error handling) directly testable
* without importing the SvelteKit route (which transitively loads the native
* database layer) or leaving global mock.module() state behind.
*
* The authorization regression tests in tests/preview-env-security.test.ts
* execute this function with injected fakes. The tested function IS the
* production pipeline invoked by the POST route — if the route's
* `auth.can('git','edit')` gate is removed, the tests break.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface PreviewEnvDependencies {
/** Resolve the auth context for the request cookies (authorization gate). */
authorize: (cookies: unknown) => Promise<PreviewEnvAuthContext>;
/** Look up an existing repository by id. */
getGitRepository: (id: number) => Promise<PreviewEnvRepository | null>;
/** Look up a stored credential by id. */
getGitCredential: (id: number) => Promise<PreviewEnvCredential | null>;
/** Clone the repo and read env files. */
previewRepoEnvFiles: (opts: PreviewEnvPreviewOptions) => Promise<PreviewEnvPreviewResult>;
/** SSRF + transport denylist guard. Throws on unsafe target. */
assertSafeRepoTarget: (url: string) => void;
/** Credential-exfiltration defense. Throws on host mismatch. */
assertCredentialHostMatch: (url: string, credential: PreviewEnvCredential) => void;
}
export interface PreviewEnvAuthContext {
authEnabled: boolean;
isAuthenticated: boolean;
user: unknown;
isAdmin: boolean;
isEnterprise: boolean;
can: (resource: string, action: string) => Promise<boolean>;
}
export interface PreviewEnvRepository {
url: string;
branch: string;
credentialId: number | null;
}
export interface PreviewEnvCredential {
id: number;
name: string;
authType: string;
username: string;
secret?: string;
}
export interface PreviewEnvPreviewOptions {
repoUrl: string;
branch: string;
credential: PreviewEnvCredential | null;
composePath: string;
envFilePath: string | null;
}
export interface PreviewEnvPreviewResult {
vars: Record<string, string>;
sources: Record<string, string>;
error?: string;
}
/**
* The result of the preview-env pipeline. The route maps this to an HTTP
* response (status + json body).
*/
export type PreviewEnvOutcome =
| { kind: 'success'; vars: Record<string, string>; sources: Record<string, string> }
| { kind: 'permission-denied' }
| { kind: 'bad-request'; message: string; vars?: Record<string, string>; sources?: Record<string, string> }
| { kind: 'not-found' }
| { kind: 'error'; message: string };
// ---------------------------------------------------------------------------
// Handler core
// ---------------------------------------------------------------------------
/**
* The preview-env handler pipeline. This is the SAME code path the POST route
* executes (the route resolves the real dependencies and forwards to this
* function), so testing this function with injected fakes exercises the real
* production logic — not a mirror.
*
* The `git:edit` permission gate (PR #1343) runs BEFORE `request.json()`
* (attacker-controlled input), repository/credential lookup, URL processing,
* and any git subprocess.
*/
export async function handlePreviewEnv(
deps: PreviewEnvDependencies,
args: { request: { json: () => Promise<any> }; cookies: unknown }
): Promise<PreviewEnvOutcome> {
const auth = await deps.authorize(args.cookies);
// Permission gate (PR #1343): the endpoint clones a USER-SUPPLIED URL and
// reads env files from it, so it is gated on the git:edit permission —
// the EXACT same model as POST /api/git/branches. This single check covers
// every authenticated case:
// - auth disabled -> can('git','edit') is true, allowed to continue;
// - auth enabled + unauthenticated -> can('git','edit') is false -> 403;
// - auth enabled + read-only -> can('git','edit') is false -> 403;
// - auth enabled + git:edit -> allowed to continue.
// (There is deliberately no separate 401 branch — an unauthenticated
// request simply lacks the git:edit permission and is denied, matching
// /api/git/branches.)
if (auth.authEnabled && !(await auth.can('git', 'edit'))) {
return { kind: 'permission-denied' };
}
try {
const data = await args.request.json();
if (!data.composePath || typeof data.composePath !== 'string') {
return { kind: 'bad-request', message: 'Compose path is required' };
}
let repoUrl: string;
let branch: string = 'main';
let credentialId: number | null = null;
if (data.repositoryId) {
// Use existing repository
const repo = await deps.getGitRepository(data.repositoryId);
if (!repo) {
return { kind: 'not-found' };
}
repoUrl = repo.url;
// An explicit branch (e.g. a per-stack override being previewed)
// wins over the repository default.
branch = typeof data.branch === 'string' && data.branch.trim() ? data.branch.trim() : repo.branch;
credentialId = repo.credentialId;
} else if (data.url) {
// New repository details
repoUrl = data.url;
branch = data.branch || 'main';
credentialId = data.credentialId || null;
} else {
return { kind: 'bad-request', message: 'Either repositoryId or url is required' };
}
// Get credential if specified
let credential: PreviewEnvCredential | null = null;
if (credentialId) {
credential = await deps.getGitCredential(credentialId);
}
// Security (PR #1343 maintainer review): the preview endpoint clones a
// USER-SUPPLIED URL and reads env files from it. Run the shared guards
// BEFORE previewRepoEnvFiles spawns git / reads files.
// 1. assertSafeRepoTarget — SSRF + transport denylist.
try {
deps.assertSafeRepoTarget(repoUrl);
} catch (e: any) {
return { kind: 'bad-request', message: e.message || 'Invalid repository URL' };
}
// 2. assertCredentialHostMatch — a raw url may only be paired with a
// stored credential plausibly for that host (exfiltration defense).
// The repositoryId path is safe (user's own stored config).
if (data.url && credentialId && credential) {
try {
deps.assertCredentialHostMatch(repoUrl, credential);
} catch (e: any) {
return { kind: 'bad-request', message: e.message || 'Invalid repository URL' };
}
}
const result = await deps.previewRepoEnvFiles({
repoUrl,
branch,
credential,
composePath: data.composePath,
envFilePath: data.envFilePath || null
});
if (result.error) {
return { kind: 'bad-request', message: result.error, vars: {}, sources: {} };
}
return { kind: 'success', vars: result.vars, sources: result.sources };
} catch (error: any) {
return { kind: 'error', message: error.message || 'Failed to preview env files' };
}
}