* 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
81 lines
3.4 KiB
TypeScript
81 lines
3.4 KiB
TypeScript
/**
|
|
* Pure branch-selection helpers for git stacks.
|
|
*
|
|
* These functions are dependency-free and safe to import from the browser and
|
|
* from unit tests without pulling in native/server initialisation
|
|
* (better-sqlite3 / argon2, etc). Both the production code (sync/deploy in
|
|
* src/lib/server/git.ts, the stacks routes) and the stacks UI import them, so
|
|
* a single implementation is the source of truth for "which branch does this
|
|
* stack deploy from" — the tests in tests/git-stack-branch.test.ts exercise
|
|
* these exact functions rather than a mirrored copy.
|
|
*/
|
|
|
|
/**
|
|
* The effective branch a git stack deploys from.
|
|
*
|
|
* A per-stack branch override (git_stacks.branch) wins; when it is unset,
|
|
* null, empty, or whitespace-only, the repository's default branch is used.
|
|
*/
|
|
export function resolveStackBranch(
|
|
gitStack: { branch: string | null } | null | undefined,
|
|
repository: { branch: string }
|
|
): string {
|
|
const override = gitStack?.branch?.trim();
|
|
return override || repository.branch;
|
|
}
|
|
|
|
/**
|
|
* Effective branch for display in the stacks UI (list badge / stack modal
|
|
* header). Follows the same rule as resolveStackBranch, but tolerates a
|
|
* missing repository and reports whether the shown branch is a per-stack
|
|
* override:
|
|
*
|
|
* - branch: the per-stack override when set (non-blank), else the
|
|
* repository default (or undefined when neither is available).
|
|
* - perStack: true when the shown branch is a per-stack override (a
|
|
* non-blank stored value), else null (shown branch is the repository
|
|
* default / nothing set).
|
|
*
|
|
* Consistency: a blank (empty / whitespace-only) override is NOT treated as a
|
|
* per-stack branch — it falls back to the repository default, exactly as
|
|
* resolveStackBranch does for deploy/sync. Only a non-blank stored override is
|
|
* shown as a per-stack branch.
|
|
*/
|
|
export function effectiveStackBranch(
|
|
gitStack: { branch?: string | null } | null | undefined,
|
|
repository: { branch?: string } | null | undefined
|
|
): { branch: string | undefined; perStack: boolean | null } {
|
|
const raw = gitStack?.branch ?? null;
|
|
const perStackBranch = typeof raw === 'string' && raw.trim() ? raw.trim() : null;
|
|
return {
|
|
branch: perStackBranch || (repository?.branch || undefined),
|
|
perStack: perStackBranch ? true : null
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalise the `branch` field of the PUT /api/git/stacks/:id partial update
|
|
* into a next stored value.
|
|
*
|
|
* Semantics (the API contract the stacks route implements):
|
|
* - key ABSENT in the body -> leave the current override untouched
|
|
* ({ next: current, changed: false });
|
|
* - explicit `null` -> clear the override so the stack inherits the
|
|
* repository default ({ next: null, changed: true });
|
|
* - non-blank string -> set the per-stack override, stored
|
|
* whitespace-trimmed ({ next: <trimmed>, changed: true });
|
|
* - blank / whitespace-only -> clear the override, normalised to null so a
|
|
* blank save is treated as "inherit repository default" consistently
|
|
* ({ next: null, changed: true }).
|
|
*/
|
|
export function normalizeStackBranchUpdate(
|
|
current: string | null,
|
|
body: Record<string, unknown>
|
|
): { next: string | null; changed: boolean } {
|
|
if (!('branch' in body)) return { next: current, changed: false };
|
|
const value = body.branch;
|
|
if (value === null) return { next: null, changed: true };
|
|
const trimmed = typeof value === 'string' ? value.trim() : '';
|
|
return { next: trimmed || null, changed: true };
|
|
}
|