branch selector on git stack, with per-stack branch override
This commit is contained in:
12 files changed
+85
-457
No files matched your search
@@ -1,29 +1,21 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getGitRepository, getGitCredential } from '$lib/server/db';
|
||||
import { getGitRepository } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { listRemoteBranches } from '$lib/server/git';
|
||||
import { assertSafeRepoTarget, assertCredentialHostMatch } from '$lib/server/git-branch-lookup';
|
||||
import { assertSafeRepoTarget } from '$lib/server/git-branch-lookup';
|
||||
|
||||
/**
|
||||
* POST /api/git/branches
|
||||
* List remote branches for a repository via `git ls-remote`.
|
||||
*
|
||||
* SECURITY (PR #1343 maintainer review, rounds 1+2): two guards run before
|
||||
* any git subprocess is spawned.
|
||||
* 1. assertSafeRepoTarget — the shared SSRF policy (delegated to
|
||||
* isSafeNotificationUrl, src/lib/server/url-safety.ts): loopback,
|
||||
* link-local / cloud-metadata and other reserved/dangerous targets are
|
||||
* rejected, while ordinary private-LAN addresses are INTENTIONALLY
|
||||
* ALLOWED so self-hosted Git servers on RFC1918 ranges (10.x /
|
||||
* 192.168.x / 172.16-31.x) keep working. Runs on BOTH the `url` and
|
||||
* `repositoryId` paths (a stored repository's URL could also point
|
||||
* internal).
|
||||
* 2. assertCredentialHostMatch — a raw `url` may only be paired with a
|
||||
* stored `credentialId` when the credential is plausibly for that host
|
||||
* (credential-exfiltration defense). The `repositoryId` path is safe —
|
||||
* the pairing is the user's own stored repository config, not an
|
||||
* attacker-chosen url+credential combo.
|
||||
* SECURITY: assertSafeRepoTarget runs before any git subprocess is spawned —
|
||||
* the shared SSRF policy (delegated to isSafeNotificationUrl,
|
||||
* src/lib/server/url-safety.ts): loopback, link-local / cloud-metadata and
|
||||
* other reserved/dangerous targets are rejected, while ordinary private-LAN
|
||||
* addresses are INTENTIONALLY ALLOWED so self-hosted Git servers on RFC1918
|
||||
* ranges (10.x / 192.168.x / 172.16-31.x) keep working. Runs on BOTH the `url`
|
||||
* and `repositoryId` paths (a stored repository's URL could also point internal).
|
||||
* The `ls-remote` itself is bounded by a hard timeout (see listRemoteBranches
|
||||
* in src/lib/server/git.ts) — clone/pull/fetch stay unbounded.
|
||||
*
|
||||
@@ -38,12 +30,12 @@ import { assertSafeRepoTarget, assertCredentialHostMatch } from '$lib/server/git
|
||||
/**
|
||||
* @openapi
|
||||
* summary: List remote branches for a git repository via `git ls-remote`
|
||||
* description: Accepts either repositoryId (an existing repository, using its stored URL and credential) or url + credentialId (a new repository). SECURITY: the repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. A raw url may only be paired with a stored credentialId whose username plausibly matches that host (SSH credentials are always allowed). The ls-remote is bounded by a hard timeout.
|
||||
* description: Accepts either repositoryId (an existing repository, using its stored URL and credential) or url + credentialId (a new repository). SECURITY: the repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. The ls-remote is bounded by a hard timeout.
|
||||
* body: {repositoryId:integer, url:string, credentialId:integer}
|
||||
* body-example: {"url":"https://github.com/example/repo.git","credentialId":2}
|
||||
* resp-200: {branches:array<string>!}
|
||||
* resp-200-example: {"branches":["main","develop","feature/test"]}
|
||||
* resp-400: The URL points at a loopback/link-local/metadata/reserved target, the credential does not match the URL host, or neither repositoryId nor url was supplied
|
||||
* resp-400: The URL points at a loopback/link-local/metadata/reserved target, or neither repositoryId nor url was supplied
|
||||
* resp-403: Permission denied (requires git:edit)
|
||||
* resp-404: The referenced repository does not exist
|
||||
* resp-500: Failed to fetch branches (ls-remote error or timeout)
|
||||
@@ -90,22 +82,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
return json({ error: e.message || 'Invalid repository URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Guard 2 (credential exfiltration): when a raw url is paired with a
|
||||
// stored credential, verify the credential is plausibly for that host.
|
||||
// The repositoryId path is safe — the pairing is the user's own stored
|
||||
// repository config, not an attacker-chosen url+credential combo.
|
||||
if (url && credId != null) {
|
||||
const credential = await getGitCredential(credId);
|
||||
if (!credential) {
|
||||
return json({ error: 'Credential not found' }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
assertCredentialHostMatch(repoUrl, credential);
|
||||
} catch (e: any) {
|
||||
return json({ error: e.message || 'Invalid repository URL' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await listRemoteBranches({ url: repoUrl, credentialId: credId });
|
||||
|
||||
if (result.error) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getGitRepository, getGitCredential } from '$lib/server/db';
|
||||
import { previewRepoEnvFiles } from '$lib/server/git';
|
||||
import { assertSafeRepoTarget, assertCredentialHostMatch } from '$lib/server/git-branch-lookup';
|
||||
import { assertSafeRepoTarget } from '$lib/server/git-branch-lookup';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { handlePreviewEnv, type PreviewEnvAuthContext, type PreviewEnvCredential, type PreviewEnvRepository, type PreviewEnvPreviewOptions, type PreviewEnvPreviewResult } from '$lib/server/preview-env-handler';
|
||||
/**
|
||||
@@ -10,22 +10,17 @@ import { handlePreviewEnv, type PreviewEnvAuthContext, type PreviewEnvCredential
|
||||
* Clone a git repository to a temp directory and read env files for preview.
|
||||
* Used when creating a new git stack to populate the env editor.
|
||||
*
|
||||
* SECURITY (PR #1343 maintainer review): the endpoint clones a
|
||||
* SECURITY: the endpoint clones a
|
||||
* USER-SUPPLIED URL and reads env files from it, so it is gated on the
|
||||
* `git:edit` permission (same model as /api/git/branches) — an authenticated
|
||||
* read-only user cannot use it to clone an arbitrary repository. Three guards
|
||||
* read-only user cannot use it to clone an arbitrary repository. Two guards
|
||||
* run BEFORE any git subprocess is spawned / files are read:
|
||||
* 1. assertSafeRepoTarget — the shared SSRF policy. Loopback, link-local /
|
||||
* cloud-metadata and other reserved/dangerous targets are rejected, while
|
||||
* ordinary private-LAN addresses are intentionally allowed so self-hosted
|
||||
* Git servers on RFC1918 ranges (10.x / 192.168.x / 172.16-31.x) keep
|
||||
* working. The ext::/file:: transports and local paths are rejected.
|
||||
* 2. assertCredentialHostMatch — a raw `url` may only be paired with a
|
||||
* stored `credentialId` when the credential is plausibly for that host
|
||||
* (credential-exfiltration defense). The `repositoryId` path is safe — the
|
||||
* pairing is the user's own stored repository config, not an
|
||||
* attacker-chosen url+credential combo.
|
||||
* 3. repoFilePath — composePath/envFilePath are constrained to stay inside
|
||||
* 2. repoFilePath — composePath/envFilePath are constrained to stay inside
|
||||
* the cloned repository (path traversal).
|
||||
*
|
||||
* Body: {
|
||||
@@ -48,11 +43,11 @@ import { handlePreviewEnv, type PreviewEnvAuthContext, type PreviewEnvCredential
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Clone a repo to a temp dir and preview its merged env-file variables for the git-stack env editor
|
||||
* description: repositoryId from GET /api/git/repositories. credentialId from GET /api/git/credentials. SECURITY: requires the git:edit permission when authentication is enabled (403 otherwise). The repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. The ext::/file:: transports and local paths are rejected; the raw url may only be paired with a stored credentialId whose username plausibly matches that host (exfiltration defense); and composePath/envFilePath are constrained to stay inside the cloned repository (path traversal).
|
||||
* description: repositoryId from GET /api/git/repositories. credentialId from GET /api/git/credentials. SECURITY: requires the git:edit permission when authentication is enabled (403 otherwise). The repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. The ext::/file:: transports and local paths are rejected; and composePath/envFilePath are constrained to stay inside the cloned repository (path traversal).
|
||||
* body: {repositoryId:integer, url:string, branch:string, credentialId:integer, composePath:string!, envFilePath:string}
|
||||
* body-example: {"repositoryId":3,"composePath":"docker-compose.yml","envFilePath":".env.prod"}
|
||||
* resp-200: {vars:object!, sources:object!}
|
||||
* resp-400: composePath missing, neither repositoryId nor url supplied, the URL points at a loopback/link-local/metadata/reserved target, the URL is an unsupported transport (ext::/file::), the credential does not match the URL host, the compose/env path escapes the repository, or the repo/env-file preview reported an error
|
||||
* resp-400: composePath missing, neither repositoryId nor url supplied, the URL points at a loopback/link-local/metadata/reserved target, the URL is an unsupported transport (ext::/file::), the compose/env path escapes the repository, or the repo/env-file preview reported an error
|
||||
* resp-403: Permission denied (requires the git:edit permission — same model as /api/git/branches; an unauthenticated or read-only user is denied here rather than via a separate 401)
|
||||
* resp-404: The referenced repository does not exist
|
||||
* resp-500: Failed to preview the env files (clone or read error)
|
||||
@@ -61,8 +56,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
// Thin SvelteKit route wrapper. Resolves the real dependencies and calls
|
||||
// the import-light, dependency-injected handler core
|
||||
// (src/lib/server/preview-env-handler.ts), which runs the EXACT production
|
||||
// pipeline: the `git:edit` permission gate, body parsing, SSRF/credential
|
||||
// guards, and the env-file preview. This route performs the HTTP/status
|
||||
// pipeline: the `git:edit` permission gate, body parsing, the SSRF
|
||||
// guard, and the env-file preview. This route performs the HTTP/status
|
||||
// mapping so the OpenAPI generator (which statically scans the handler
|
||||
// body) sees all status codes.
|
||||
const deps = {
|
||||
@@ -75,8 +70,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
getGitCredential: async (id: number) => getGitCredential(id) as Promise<PreviewEnvCredential | null>,
|
||||
previewRepoEnvFiles: async (opts: PreviewEnvPreviewOptions) =>
|
||||
previewRepoEnvFiles(opts) as Promise<PreviewEnvPreviewResult>,
|
||||
assertSafeRepoTarget,
|
||||
assertCredentialHostMatch: assertCredentialHostMatch as (url: string, credential: PreviewEnvCredential) => void
|
||||
assertSafeRepoTarget
|
||||
};
|
||||
const outcome = await handlePreviewEnv(deps, { request, cookies });
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { testRepositoryConfig } from '$lib/server/git';
|
||||
import { getGitCredential } from '$lib/server/db';
|
||||
import { assertSafeRepoTarget, assertCredentialHostMatch } from '$lib/server/git-branch-lookup';
|
||||
import { assertSafeRepoTarget } from '$lib/server/git-branch-lookup';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
|
||||
/**
|
||||
@@ -19,12 +19,12 @@ import { authorize } from '$lib/server/authorize';
|
||||
/**
|
||||
* @openapi
|
||||
* summary: Test an unsaved repository configuration (url/branch/credentialId) before creating it
|
||||
* description: credentialId from GET /api/git/credentials. SECURITY: the repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. The ext::/file:: transports and local paths are rejected; the raw url may only be paired with a stored credentialId whose username plausibly matches that host (exfiltration defense).
|
||||
* description: credentialId from GET /api/git/credentials. SECURITY: the repository target is checked against the shared SSRF policy — loopback, link-local/cloud-metadata and other reserved dangerous targets are rejected, while ordinary private-LAN addresses are intentionally allowed so self-hosted Git servers remain supported. The ext::/file:: transports and local paths are rejected.
|
||||
* body: {url:string!, branch:string, credentialId:integer}
|
||||
* body-example: {"url":"https://github.com/example/homelab.git","branch":"main","credentialId":2}
|
||||
* resp-200: {success:boolean!, error:string}
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-400: The url field is missing, the URL points at a loopback/link-local/metadata/reserved target, the URL is an unsupported transport, or the credential does not match the URL host
|
||||
* resp-400: The url field is missing, the URL points at a loopback/link-local/metadata/reserved target, the URL is an unsupported transport.
|
||||
* resp-403: Caller lacks the settings:manage permission
|
||||
* resp-404: The referenced credential does not exist
|
||||
* resp-500: The connectivity test failed
|
||||
@@ -42,7 +42,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
return json({ error: 'Repository URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Security (PR #1343 maintainer review): the test endpoint spawns git
|
||||
// Security: the test endpoint spawns git
|
||||
// (ls-remote + clone) with a USER-SUPPLIED url + credentialId. Run the
|
||||
// shared guards BEFORE testRepositoryConfig spawns anything.
|
||||
// 1. assertSafeRepoTarget — SSRF + transport denylist.
|
||||
@@ -51,18 +51,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
} catch (e: any) {
|
||||
return json({ success: false, error: e.message || 'Invalid repository URL' }, { status: 400 });
|
||||
}
|
||||
// 2. assertCredentialHostMatch — raw url + stored credential must be
|
||||
// plausibly for that host (exfiltration defense).
|
||||
if (body.credentialId != null && body.credentialId !== undefined) {
|
||||
const credential = await getGitCredential(body.credentialId);
|
||||
if (!credential) {
|
||||
return json({ success: false, error: 'Credential not found' }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
assertCredentialHostMatch(body.url, credential);
|
||||
} catch (e: any) {
|
||||
return json({ success: false, error: e.message || 'Invalid repository URL' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await testRepositoryConfig({
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
onchange?: (value: string) => void;
|
||||
/** Called when the user clears the field (empty value). */
|
||||
onclear?: () => void;
|
||||
/** When set (with onclear), shows a top row that resets to this label,
|
||||
* e.g. "Repository default (main)". */
|
||||
clearLabel?: string;
|
||||
id?: string;
|
||||
class?: string;
|
||||
placeholder?: string;
|
||||
@@ -49,6 +52,7 @@
|
||||
loading = false,
|
||||
onchange,
|
||||
onclear,
|
||||
clearLabel,
|
||||
id,
|
||||
class: className,
|
||||
placeholder = 'main'
|
||||
@@ -117,6 +121,13 @@
|
||||
{#if loading}
|
||||
<Command.Empty>Loading branches…</Command.Empty>
|
||||
{:else}
|
||||
{#if clearLabel && onclear && !searchQuery.trim()}
|
||||
<Command.Item value="__repo_default__" onSelect={clearBranch}>
|
||||
<Check class={cn('mr-2 h-4 w-4', value ? 'opacity-0' : 'opacity-100')} />
|
||||
<GitBranch class="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
<span class="truncate">{clearLabel}</span>
|
||||
</Command.Item>
|
||||
{/if}
|
||||
{#if filteredBranches.length > 0}
|
||||
<Command.Group heading="Branches">
|
||||
{#each filteredBranches as branch}
|
||||
|
||||
@@ -163,7 +163,6 @@
|
||||
// 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
|
||||
@@ -885,36 +884,17 @@
|
||||
{#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>
|
||||
<BranchCombobox
|
||||
id="existing-repo-branch"
|
||||
value={formBranch ?? ''}
|
||||
branches={branches}
|
||||
loading={branchesLoading}
|
||||
placeholder="Repository default ({selectedRepo.branch})"
|
||||
clearLabel="Repository default ({selectedRepo.branch})"
|
||||
onchange={(v) => { formBranch = v; }}
|
||||
onclear={() => { formBranch = null; }}
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">Branch this stack deploys from. Leave empty to follow the branch configured on the repository ({selectedRepo.branch}).</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
@@ -1037,8 +1017,8 @@
|
||||
{#if gitStack && selectedRepo}
|
||||
<div class="space-y-2">
|
||||
<Label>Repository</Label>
|
||||
<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" />
|
||||
<div class="flex h-9 items-center gap-2 rounded-md border border-input bg-muted/50 px-3 py-1 text-sm text-muted-foreground">
|
||||
<FolderGit2 class="w-4 h-4 shrink-0" />
|
||||
<span class="truncate" title={selectedRepo.url}>{selectedRepo.url}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1047,36 +1027,17 @@
|
||||
{#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>
|
||||
<BranchCombobox
|
||||
id="stack-branch"
|
||||
value={formBranch ?? ''}
|
||||
branches={branches}
|
||||
loading={branchesLoading}
|
||||
placeholder="Repository default ({selectedRepo.branch})"
|
||||
clearLabel="Repository default ({selectedRepo.branch})"
|
||||
onchange={(v) => { formBranch = v; }}
|
||||
onclear={() => { formBranch = null; }}
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">Branch this stack deploys from. Leave empty to follow the branch configured on the repository ({selectedRepo.branch}).</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in new issue
Block a user