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:
1 parent
0885e8245f
commit
28bb291c32
29 files changed
+38984
-106
No files matched your search
@@ -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'
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user