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

+6 -2
View File
@@ -51,6 +51,7 @@
import { ErrorDialog } from '$lib/components/ui/error-dialog';
import { formatHostPortUrl } from '$lib/utils/url';
import { formatBytes, formatBytesCompact } from '$lib/utils/format';
import { effectiveStackBranch } from '$lib/git-stack-branch';
type SortField = 'name' | 'containers' | 'status' | 'cpu' | 'memory';
type SortDirection = 'asc' | 'desc';
@@ -938,7 +939,7 @@
return stack.status;
}
async function openGitModal(gitStack?: any) {
async function openGitModal(gitStack: any = undefined) {
editingGitStack = gitStack || null;
// Fetch repositories and credentials before opening modal
try {
@@ -1115,10 +1116,13 @@
editingStackName = name;
stackModalReadonly = true;
const src = getStackSource(name);
// Effective branch: per-stack override wins, else repository default
// (shared with the server-side resolver in src/lib/git-stack-branch.ts).
const eff = effectiveStackBranch(src?.gitStack ?? null, src?.repository ?? undefined);
stackModalGitInfo = {
commit: src?.gitStack?.lastCommit || undefined,
url: src?.repository?.url || undefined,
branch: src?.repository?.branch || undefined
branch: eff.branch
};
showEditModal = true;
}
+147
View File
@@ -0,0 +1,147 @@
<script lang="ts">
import { ChevronDown, Check, GitBranch, Loader2 } from 'lucide-svelte';
import * as Command from '$lib/components/ui/command';
import * as Popover from '$lib/components/ui/popover';
import { buttonVariants } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
/**
* Free-text, searchable branch picker.
*
* Reuses the repository's existing Popover + Command primitives (the same
* interaction/style pattern as TimezoneSelector.svelte) so the user can
* both pick a *discovered* branch and type an *arbitrary* branch name.
*
* Behaviour contract (maintainer review for the new-repository branch
* field):
* - fetched branches appear in the list and are searchable/selectable;
* - typing a branch NOT in the list offers "Use \"<query>\"", and clicking
* it (or pressing Enter) selects that exact value;
* - if branch enumeration fails or returns nothing, the user can still
* type and select any branch manually (the "Use \"<query>\"" row always
* appears when the trimmed query is non-blank);
* - an existing value that a later enumeration did NOT return is retained
* (never silently reset), and the trigger keeps showing it;
* - while enumeration is in flight a loader is shown but the field stays
* usable;
* - keyboard selection (ArrowUp/Down + Enter) works for both fetched rows
* and the free-text row.
*/
interface Props {
/** Current branch value (e.g. "main"). */
value: string;
/** Discovered branch names (from /api/git/branches). */
branches: string[];
/** True while branch enumeration is in flight. */
loading: boolean;
/** Called with the selected value (a fetched branch or the typed name). */
onchange?: (value: string) => void;
/** Called when the user clears the field (empty value). */
onclear?: () => void;
id?: string;
class?: string;
placeholder?: string;
}
let {
value = $bindable('main'),
branches = [],
loading = false,
onchange,
onclear,
id,
class: className,
placeholder = 'main'
}: Props = $props();
let open = $state(false);
let searchQuery = $state('');
function selectBranch(branch: string) {
value = branch;
open = false;
searchQuery = '';
onchange?.(branch);
}
function clearBranch() {
value = '';
open = false;
searchQuery = '';
onclear?.();
}
// Filtered fetched branches, ordered so the current value (if not in the
// fetched list) still surfaces near the top for easy re-selection.
const filteredBranches = $derived(
searchQuery
? branches.filter((b) => b.toLowerCase().includes(searchQuery.toLowerCase()))
: branches
);
// Offer the typed name as a selectable row when it is non-blank and is not
// already one of the fetched branches.
const freeTextBranch = $derived(
searchQuery.trim() && !branches.includes(searchQuery.trim()) ? searchQuery.trim() : ''
);
// Display: show the current value; fall back to the placeholder when empty
// (e.g. no branch chosen -> "main").
const displayValue = value || placeholder;
</script>
<Popover.Root bind:open>
<Popover.Trigger>
<span
role="combobox"
aria-expanded={open}
class={cn(buttonVariants({ variant: 'outline' }), 'w-full justify-between cursor-pointer select-none', className)}
{id}
>
<span class="flex items-center gap-2 truncate">
<GitBranch class="h-4 w-4 shrink-0 text-muted-foreground" />
{#if loading}
<Loader2 class="h-4 w-4 animate-spin" />
<span class="text-muted-foreground">Loading branches…</span>
{:else}
<span class="truncate">{displayValue}</span>
{/if}
</span>
<ChevronDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</span>
</Popover.Trigger>
<Popover.Content class="w-[350px] p-0 z-[200]" align="start">
<Command.Root shouldFilter={false}>
<Command.Input bind:value={searchQuery} placeholder="Type a branch name…" />
<Command.List class="max-h-[300px]">
{#if loading}
<Command.Empty>Loading branches…</Command.Empty>
{:else}
{#if filteredBranches.length > 0}
<Command.Group heading="Branches">
{#each filteredBranches as branch}
<Command.Item value={branch} onSelect={() => selectBranch(branch)}>
<Check class={cn('mr-2 h-4 w-4', value === branch ? 'opacity-100' : 'opacity-0')} />
<span class="truncate">{branch}</span>
</Command.Item>
{/each}
</Command.Group>
{/if}
{#if freeTextBranch}
<Command.Group heading="Custom">
<Command.Item value={freeTextBranch} onSelect={() => selectBranch(freeTextBranch)}>
<GitBranch class="mr-2 h-4 w-4" />
<span class="truncate">Use "{freeTextBranch}"</span>
</Command.Item>
</Command.Group>
{/if}
{#if filteredBranches.length === 0 && !freeTextBranch && !loading}
<Command.Empty>
Type a branch name (e.g. "main") or pick from the list.
</Command.Empty>
{/if}
{/if}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
+14 -3
View File
@@ -3,14 +3,22 @@
import { GitBranch, GitCommitHorizontal } from 'lucide-svelte';
import { forgeIcon } from '$lib/utils/git-forge';
import { appSettings } from '$lib/stores/settings';
import { effectiveStackBranch } from '$lib/git-stack-branch';
interface Props {
source: { repository?: { url?: string; branch?: string } | null; gitStack?: { lastCommit?: string | null } | null };
source: { repository?: { url?: string; branch?: string } | null; gitStack?: { lastCommit?: string | null; branch?: string | null } | null };
}
let { source }: Props = $props();
const ForgeIcon = $derived(forgeIcon(source.repository?.url));
const showHash = $derived(!!source.gitStack?.lastCommit && $appSettings.showGitCommitHash);
// Effective branch: per-stack override wins, else repository default
// (shared with the server-side resolver in src/lib/git-stack-branch.ts).
const eff = $derived(
effectiveStackBranch(source.gitStack ?? null, source.repository ?? undefined)
);
const effectiveBranch = eff.branch;
const isPerStackBranch = eff.perStack === true;
// A tooltip is only worth showing when there's real git info in it.
const hasGitInfo = $derived(!!source.gitStack?.lastCommit || !!source.repository);
</script>
@@ -41,10 +49,13 @@
</div>
{/if}
{#if source.repository}
{#if source.repository.branch}
{#if effectiveBranch}
<div class="flex items-center gap-2">
<GitBranch class="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
<span class="text-xs">{source.repository.branch}</span>
<span class="text-xs">{effectiveBranch}</span>
{#if isPerStackBranch}
<span class="text-[10px] text-muted-foreground">(per-stack)</span>
{/if}
</div>
{/if}
<div class="flex items-center gap-2">
+166 -9
View File
@@ -4,7 +4,6 @@
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Label } from '$lib/components/ui/label';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap, FolderOpen, Ban, TriangleAlert, Settings2, Archive } from 'lucide-svelte';
@@ -17,6 +16,7 @@
import CronEditor from '$lib/components/cron-editor.svelte';
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
import SecretProviderPicker from '$lib/components/SecretProviderPicker.svelte';
import BranchCombobox from './BranchCombobox.svelte';
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
import { toast } from 'svelte-sonner';
import { focusFirstInput } from '$lib/utils';
@@ -45,13 +45,14 @@
name: string;
url: string;
branch: string;
credential_id: number | null;
credentialId: number | null;
}
interface GitStack {
id: number;
stackName: string;
repositoryId: number;
branch?: string | null; // Per-stack branch override; null = use repository default
environmentId: number | null;
composePath: string;
envFilePath: string | null;
@@ -149,6 +150,21 @@
let showExistsWarning = $state(false);
let errors = $state<{ stackName?: string; repository?: string; repoName?: string; repoUrl?: string; webhookSecret?: string }>({});
// Branch selection
let formBranch = $state<string | null>(null);
let branches = $state<string[]>([]);
let branchesLoading = $state(false);
// Monotonic token that guards against a stale branch-enumeration response
// overwriting `branches` for a newer repository URL (the $effect below can
// fire multiple times as the repo selection changes; a slow response for
// repo A must not clobber the branch list belonging to repo B).
let branchesFetchSeq = 0;
// Sentinel select value meaning "no per-stack override — use the repository's
// default branch". Contains ':' which is invalid in git refs, so it can never
// collide with a real branch name.
const REPO_DEFAULT_BRANCH_VALUE = ':repository-default:';
// Stack name validation: Docker Compose requires lowercase; must start with a
// letter or number, and contain only lowercase letters, numbers, hyphens, underscores
const STACK_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*$/;
@@ -361,6 +377,8 @@
if (formRepoMode === 'existing') {
body.repositoryId = formRepositoryId;
// Send the selected branch so env files are previewed from it (per-stack override)
body.branch = formBranch || undefined;
} else {
body.url = formNewRepoUrl;
body.branch = formNewRepoBranch || 'main';
@@ -448,9 +466,11 @@
formForceRedeploy = gitStack.forceRedeploy ?? false;
formDeployNow = false;
formSecretProviderId = null;
// Load secret provider binding
loadSecretProviderBindingForStack(gitStack.stackName);
// Per-stack branch override; null means "use the repository default"
formBranch = gitStack.branch ?? null;
// Load env files and overrides SYNCHRONOUSLY to avoid race conditions
// Wait for all loads to complete before allowing any other effect to run
@@ -497,6 +517,41 @@
}
}
async function fetchBranches() {
const seq = ++branchesFetchSeq;
branchesLoading = true;
branches = [];
try {
const body: Record<string, any> = {};
if (formRepoMode === 'existing' && formRepositoryId) {
body.repositoryId = formRepositoryId;
} else if (formRepoMode === 'new' && formNewRepoUrl) {
body.url = formNewRepoUrl;
body.credentialId = formNewRepoCredentialId;
} else {
return;
}
const response = await fetch('/api/git/branches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
// A newer fetch (or a repo change) superseded this one — drop the
// stale response so it cannot overwrite the new repo's branch list.
if (seq !== branchesFetchSeq) return;
if (response.ok) {
const data = await response.json();
if (seq !== branchesFetchSeq) return;
branches = data.branches || [];
}
} catch (e) {
if (seq !== branchesFetchSeq) return;
console.error('Failed to fetch branches:', e);
} finally {
if (seq === branchesFetchSeq) branchesLoading = false;
}
}
async function saveGitStack(deployAfterSave: boolean = false) {
errors = {};
let hasErrors = false;
@@ -589,6 +644,9 @@
if (formRepoMode === 'existing') {
body.repositoryId = formRepositoryId;
// Per-stack branch override — sent on both create and update so the
// stack payload is the single source of truth (null = inherit repo default)
body.branch = formBranch || null;
} else {
// Create new repo inline
body.repoName = formNewRepoName;
@@ -616,9 +674,10 @@
}
// Check if deployment failed
if (data.deployResult && !data.deployResult.success) {
const deployResult = data.deployResult as { success?: boolean; error?: string } | undefined;
if (deployResult && !deployResult.success) {
toast.error('Deployment failed', {
description: data.deployResult.error || 'Unknown error'
description: deployResult.error || 'Unknown error'
});
onSaved(); // Still refresh the list to show the new stack
onClose(); // Close modal, error shown as toast
@@ -634,6 +693,21 @@
}
}
// Fetch branches when repository selection changes
$effect(() => {
if (formRepoMode === 'existing' && formRepositoryId) {
void fetchBranches();
// A fresh stack inherits the repository's default until a branch is
// picked. When editing, the stored per-stack override (set in
// resetForm) must be preserved — null means repository default.
if (!gitStack) formBranch = null;
} else if (formRepoMode === 'new' && formNewRepoUrl) {
void fetchBranches();
} else {
branches = [];
}
});
// Auto-populate stack name from selected repo and compose path (only if user hasn't manually edited)
$effect(() => {
if (formRepoMode === 'existing' && formRepositoryId && !gitStack && !formStackNameUserModified) {
@@ -807,6 +881,42 @@
No repositories configured. Click "Add new" to add one.
</p>
{/if}
<!-- Branch selection for existing repository -->
{#if formRepoMode === 'existing' && selectedRepo}
<div class="space-y-2">
<Label for="existing-repo-branch">Branch</Label>
<Select.Root type="single" value={formBranch || REPO_DEFAULT_BRANCH_VALUE} onValueChange={(v) => { formBranch = v === REPO_DEFAULT_BRANCH_VALUE ? null : v; }}>
<Select.Trigger class="w-full">
<span class="flex items-center gap-2">
<GitBranch class="w-4 h-4 text-muted-foreground" />
{#if branchesLoading}
<Loader2 class="w-4 h-4 animate-spin" />
{:else if formBranch}
{formBranch}
{:else}
<span class="text-muted-foreground">Repository default ({selectedRepo.branch})</span>
{/if}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value={REPO_DEFAULT_BRANCH_VALUE}>
<span class="flex items-center gap-2">
<GitBranch class="w-4 h-4 text-muted-foreground" />
Repository default ({selectedRepo.branch})
</span>
</Select.Item>
{#if branches.length > 0}
{#each branches as branch}
{#if branch !== selectedRepo.branch}
<Select.Item value={branch}>{branch}</Select.Item>
{/if}
{/each}
{/if}
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">Branch this stack deploys from. "Repository default" follows the branch configured on the repository.</p>
</div>
{/if}
{:else}
<div class="space-y-3 p-3 border rounded-md bg-muted/30">
<div class="space-y-2">
@@ -838,7 +948,21 @@
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<Label for="new-repo-branch">Branch</Label>
<Input id="new-repo-branch" bind:value={formNewRepoBranch} placeholder="main" />
<!-- Free-text, searchable branch picker. Supports both discovered
branches and arbitrary typed names (maintainer review: a new/
private repository whose branch enumeration fails must not force the
user onto "main" — they can type the known branch name instead). The
"main" default is preserved when no branch has been chosen, and a
value not returned by enumeration is never silently reset. Server-side
Git ref validation remains authoritative. -->
<BranchCombobox
id="new-repo-branch"
value={formNewRepoBranch}
branches={branches}
loading={branchesLoading}
placeholder="main"
/>
<p class="text-xs text-muted-foreground">Branch to deploy. Type a name (e.g. "main") or pick from the list.</p>
</div>
<div class="space-y-2">
<Label for="new-repo-credential">Credential</Label>
@@ -916,13 +1040,46 @@
<div class="flex items-center gap-2 text-xs text-muted-foreground bg-muted/50 rounded-md px-3 py-2">
<FolderGit2 class="w-3.5 h-3.5 shrink-0" />
<span class="truncate" title={selectedRepo.url}>{selectedRepo.url}</span>
{#if selectedRepo.branch}
<Badge variant="outline" class="text-2xs py-0 px-1.5 shrink-0">{selectedRepo.branch}</Badge>
{/if}
</div>
</div>
{/if}
{#if gitStack && selectedRepo}
<div class="space-y-2">
<Label for="stack-branch">Branch</Label>
<Select.Root type="single" value={formBranch || REPO_DEFAULT_BRANCH_VALUE} onValueChange={(v) => { formBranch = v === REPO_DEFAULT_BRANCH_VALUE ? null : v; }}>
<Select.Trigger class="w-full">
<span class="flex items-center gap-2">
<GitBranch class="w-4 h-4 text-muted-foreground" />
{#if branchesLoading}
<Loader2 class="w-4 h-4 animate-spin" />
{:else if formBranch}
{formBranch}
{:else}
<span class="text-muted-foreground">Repository default ({selectedRepo.branch})</span>
{/if}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value={REPO_DEFAULT_BRANCH_VALUE}>
<span class="flex items-center gap-2">
<GitBranch class="w-4 h-4 text-muted-foreground" />
Repository default ({selectedRepo.branch})
</span>
</Select.Item>
{#if branches.length > 0}
{#each branches as branch}
{#if branch !== selectedRepo.branch}
<Select.Item value={branch}>{branch}</Select.Item>
{/if}
{/each}
{/if}
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">Branch this stack deploys from. "Repository default" follows the branch configured on the repository.</p>
</div>
{/if}
<div class="space-y-2">
<Label for="compose-path">Compose file path</Label>
<Input id="compose-path" bind:value={formComposePath} placeholder="compose.yaml" />