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
+38981
-103
No files matched your search
@@ -6,3 +6,4 @@ bun.lock
|
||||
data/db
|
||||
data/.encryption_key
|
||||
.last-synced-gitea
|
||||
.hermes/
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "git_stacks" ADD COLUMN "branch" text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -85,6 +85,13 @@
|
||||
"when": 1786892847678,
|
||||
"tag": "0011_pending_updates_semver",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1786967313223,
|
||||
"tag": "0012_add_git_stack_branch",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `git_stacks` ADD `branch` text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -85,6 +85,13 @@
|
||||
"when": 1786892843482,
|
||||
"tag": "0011_pending_updates_semver",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1786967313223,
|
||||
"tag": "0012_add_git_stack_branch",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -12188,6 +12188,92 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/git/branches": {
|
||||
"post": {
|
||||
"operationId": "post_api_git_branches",
|
||||
"tags": [
|
||||
"git"
|
||||
],
|
||||
"summary": "List remote branches for a git repository via `git ls-remote`",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"branches": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"branches"
|
||||
]
|
||||
},
|
||||
"example": {
|
||||
"branches": [
|
||||
"main",
|
||||
"develop",
|
||||
"feature/test"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "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"
|
||||
},
|
||||
"403": {
|
||||
"description": "Permission denied (requires git:edit)"
|
||||
},
|
||||
"404": {
|
||||
"description": "The referenced repository does not exist"
|
||||
},
|
||||
"500": {
|
||||
"description": "Failed to fetch branches (ls-remote error or timeout)"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"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.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repositoryId": {
|
||||
"type": "integer"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"credentialId": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"url": "https://github.com/example/repo.git",
|
||||
"credentialId": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/git/credentials": {
|
||||
"get": {
|
||||
"operationId": "get_api_git_credentials",
|
||||
@@ -12713,10 +12799,10 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "composePath missing, neither repositoryId nor url supplied, or the repo/env-file preview reported an error"
|
||||
"description": "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"
|
||||
},
|
||||
"401": {
|
||||
"description": "Authentication required"
|
||||
"403": {
|
||||
"description": "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)"
|
||||
},
|
||||
"404": {
|
||||
"description": "The referenced repository does not exist"
|
||||
@@ -12733,7 +12819,7 @@
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "repositoryId from GET /api/git/repositories. credentialId from GET /api/git/credentials.",
|
||||
"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).",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -13485,11 +13571,14 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "The url field is missing"
|
||||
"description": "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"
|
||||
},
|
||||
"403": {
|
||||
"description": "Caller lacks the settings:manage permission"
|
||||
},
|
||||
"404": {
|
||||
"description": "The referenced credential does not exist"
|
||||
},
|
||||
"500": {
|
||||
"description": "The connectivity test failed"
|
||||
}
|
||||
@@ -13502,7 +13591,7 @@
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "credentialId from GET /api/git/credentials.",
|
||||
"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).",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
return { stdout: result.stdout.trim(), stderr: result.stderr.trim(), code: result.exitCode };
|
||||
} catch (err: any) {
|
||||
return { stdout: '', stderr: err.message, code: 1 };
|
||||
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);
|
||||
};
|
||||
|
||||
// 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');
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getGitRepository, getGitCredential } 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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The `ls-remote` itself is bounded by a hard timeout (see listRemoteBranches
|
||||
* in src/lib/server/git.ts) — clone/pull/fetch stay unbounded.
|
||||
*
|
||||
* Body: {
|
||||
* repositoryId?: number, // Existing repository (uses its url + credential)
|
||||
* url?: string, // OR a new repository URL
|
||||
* credentialId?: number|null // Credential for the url (new-repo flow)
|
||||
* }
|
||||
*
|
||||
* Returns: { branches: string[] }
|
||||
*/
|
||||
/**
|
||||
* @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.
|
||||
* 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-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)
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('git', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { repositoryId, url, credentialId } = body as {
|
||||
repositoryId?: number;
|
||||
url?: string;
|
||||
credentialId?: number | null;
|
||||
};
|
||||
|
||||
let repoUrl: string;
|
||||
let credId: number | undefined;
|
||||
|
||||
if (repositoryId) {
|
||||
const repo = await getGitRepository(repositoryId);
|
||||
if (!repo) {
|
||||
return json({ error: 'Repository not found' }, { status: 404 });
|
||||
}
|
||||
repoUrl = repo.url;
|
||||
credId = repo.credentialId ?? undefined;
|
||||
} else if (url) {
|
||||
repoUrl = url;
|
||||
credId = credentialId ?? undefined;
|
||||
} else {
|
||||
return json({ error: 'repositoryId or url is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Guard 1 (SSRF): the shared SSRF policy rejects loopback /
|
||||
// link-local / cloud-metadata / reserved targets; ordinary
|
||||
// private-LAN addresses are intentionally allowed (self-hosted Git).
|
||||
// Runs on BOTH paths — a stored repository's URL could also point
|
||||
// internal.
|
||||
try {
|
||||
assertSafeRepoTarget(repoUrl);
|
||||
} catch (e: any) {
|
||||
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) {
|
||||
return json({ error: 'Failed to fetch branches: ' + result.error }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ branches: result.branches });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch branches:', error);
|
||||
return json({ error: 'Failed to fetch branches' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -2,13 +2,32 @@ 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 { authorize } from '$lib/server/authorize';
|
||||
|
||||
import { handlePreviewEnv, type PreviewEnvAuthContext, type PreviewEnvCredential, type PreviewEnvRepository, type PreviewEnvPreviewOptions, type PreviewEnvPreviewResult } from '$lib/server/preview-env-handler';
|
||||
/**
|
||||
* POST /api/git/preview-env
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* the cloned repository (path traversal).
|
||||
*
|
||||
* Body: {
|
||||
* repositoryId?: number, // Existing repository
|
||||
* url?: string, // OR new repo URL
|
||||
@@ -29,76 +48,52 @@ import { authorize } from '$lib/server/authorize';
|
||||
/**
|
||||
* @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.
|
||||
* 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).
|
||||
* 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, or the repo/env-file preview reported an error
|
||||
* resp-401: Authentication required
|
||||
* 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-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)
|
||||
*/
|
||||
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
|
||||
// mapping so the OpenAPI generator (which statically scans the handler
|
||||
// body) sees all status codes.
|
||||
const deps = {
|
||||
// authorize() returns the full auth context (authEnabled, can, …).
|
||||
authorize: async (_cookies: unknown) => {
|
||||
const auth = await authorize(cookies);
|
||||
return auth as PreviewEnvAuthContext;
|
||||
},
|
||||
getGitRepository: async (id: number) => getGitRepository(id) as Promise<PreviewEnvRepository | null>,
|
||||
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
|
||||
};
|
||||
const outcome = await handlePreviewEnv(deps, { request, cookies });
|
||||
|
||||
// Basic permission check - must be able to create stacks
|
||||
if (auth.authEnabled && !auth.isAuthenticated) {
|
||||
return json({ error: 'Authentication required' }, { status: 401 });
|
||||
switch (outcome.kind) {
|
||||
case 'success':
|
||||
return json({ vars: outcome.vars, sources: outcome.sources });
|
||||
case 'permission-denied':
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
case 'bad-request': {
|
||||
const body: Record<string, unknown> = { error: outcome.message };
|
||||
if (outcome.vars) body.vars = outcome.vars;
|
||||
if (outcome.sources) body.sources = outcome.sources;
|
||||
return json(body, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await request.json();
|
||||
|
||||
if (!data.composePath || typeof data.composePath !== 'string') {
|
||||
return json({ error: 'Compose path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let repoUrl: string;
|
||||
let branch: string = 'main';
|
||||
let credentialId: number | null = null;
|
||||
|
||||
if (data.repositoryId) {
|
||||
// Use existing repository
|
||||
const repo = await getGitRepository(data.repositoryId);
|
||||
if (!repo) {
|
||||
case 'not-found':
|
||||
return json({ error: 'Repository not found' }, { status: 404 });
|
||||
}
|
||||
repoUrl = repo.url;
|
||||
branch = 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 json({ error: 'Either repositoryId or url is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get credential if specified
|
||||
let credential = null;
|
||||
if (credentialId) {
|
||||
credential = await getGitCredential(credentialId);
|
||||
}
|
||||
|
||||
const result = await previewRepoEnvFiles({
|
||||
repoUrl,
|
||||
branch,
|
||||
credential,
|
||||
composePath: data.composePath,
|
||||
envFilePath: data.envFilePath || null
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return json({ vars: {}, sources: {}, error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
return json({
|
||||
vars: result.vars,
|
||||
sources: result.sources
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Failed to preview env files:', error);
|
||||
return json({ error: error.message || 'Failed to preview env files' }, { status: 500 });
|
||||
case 'error':
|
||||
return json({ error: outcome.message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
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 { authorize } from '$lib/server/authorize';
|
||||
|
||||
/**
|
||||
@@ -17,13 +19,14 @@ 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.
|
||||
* 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).
|
||||
* 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
|
||||
* 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-403: Caller lacks the settings:manage permission
|
||||
* resp-404: The referenced credential does not exist
|
||||
* resp-500: The connectivity test failed
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
@@ -39,6 +42,29 @@ 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
|
||||
// (ls-remote + clone) with a USER-SUPPLIED url + credentialId. Run the
|
||||
// shared guards BEFORE testRepositoryConfig spawns anything.
|
||||
// 1. assertSafeRepoTarget — SSRF + transport denylist.
|
||||
try {
|
||||
assertSafeRepoTarget(body.url);
|
||||
} 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({
|
||||
url: body.url,
|
||||
branch: body.branch || 'main',
|
||||
|
||||
@@ -154,6 +154,12 @@ export const POST: RequestHandler = async (event) => {
|
||||
stackName: trimmedStackName,
|
||||
environmentId: data.environmentId || null,
|
||||
repositoryId: repositoryId,
|
||||
// Per-stack branch override — only when targeting an existing repository.
|
||||
// In new-repo mode data.branch becomes the repository's default instead;
|
||||
// the stack inherits it (branch stays null).
|
||||
...(data.repositoryId && typeof data.branch === 'string' && data.branch.trim()
|
||||
? { branch: data.branch.trim() }
|
||||
: {}),
|
||||
composePath: data.composePath || 'compose.yaml',
|
||||
envFilePath: data.envFilePath || null,
|
||||
autoUpdate: data.autoUpdate || false,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars, getStackEnvVars, deleteStackEnvVars, updateStackSource } from '$lib/server/db';
|
||||
import { deleteGitStackFiles, deployGitStack } from '$lib/server/git';
|
||||
import { normalizeStackBranchUpdate } from '$lib/git-stack-branch';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
import { auditGitStack } from '$lib/server/audit';
|
||||
@@ -109,8 +110,24 @@ export const PUT: RequestHandler = async (event) => {
|
||||
}
|
||||
|
||||
const oldStackName = existing.stackName;
|
||||
|
||||
// Per-stack branch override is a partial update. The shared normalizer
|
||||
// (normalizeStackBranchUpdate, src/lib/git-stack-branch.ts) encodes the
|
||||
// API contract:
|
||||
// - `branch` key ABSENT -> leave the stored value untouched;
|
||||
// - explicit null -> clear the override (inherit repo default);
|
||||
// - blank / whitespace -> normalised to clear;
|
||||
// - non-blank string -> set the override (stored trimmed).
|
||||
// updateGitStack writes the column only when the key is defined
|
||||
// (`data.branch !== undefined`), so we pass `undefined` for the
|
||||
// "absent" case to leave the stored value untouched.
|
||||
const branchNext = normalizeStackBranchUpdate(existing.branch, data);
|
||||
const branchValue: string | null | undefined =
|
||||
'branch' in data ? branchNext.next : undefined;
|
||||
|
||||
const updated = await updateGitStack(id, {
|
||||
stackName: data.stackName,
|
||||
branch: branchValue,
|
||||
composePath: data.composePath,
|
||||
envFilePath: data.envFilePath,
|
||||
autoUpdate: data.autoUpdate,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
@@ -451,6 +469,8 @@
|
||||
|
||||
// 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" />
|
||||
|
||||
+29123
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,650 @@
|
||||
/**
|
||||
* Integration tests for POST /api/git/branches.
|
||||
*
|
||||
* SECURITY (PR #1343 maintainer review, round 2 — re-review 2026-08-21):
|
||||
*
|
||||
* 1. SSRF — the SSRF check is DELEGATED to isSafeNotificationUrl (shared,
|
||||
* URL()-based, battle-tested against every IP encoding). This file
|
||||
* exercises the MAINTAINER'S EXACT BYPASS PAYLOADS (decimal, octal,
|
||||
* short-form, hex, cloud-metadata, un-compressed v4-mapped IPv6) and
|
||||
* asserts each is BLOCKED by the new assertSafeRepoTarget. These
|
||||
* payloads ALL PASS the old hand-rolled dotted-quad parser — the tests
|
||||
* here are the regression lock so they stay closed.
|
||||
*
|
||||
* 2. Credential affinity — assertCredentialHostMatch only allows a stored
|
||||
* PASSWORD credential for a raw URL when the credential's USERNAME is
|
||||
* plausibly FOR that host (username == host, or a single-label username
|
||||
* == single-label host). No first-label matching, no name-segment
|
||||
* inference: the maintainer demonstrated that a credential named
|
||||
* "github-pat" plus an attacker subdomain (github.evil.com) would
|
||||
* otherwise deliver the decrypted PAT to the attacker. This file locks
|
||||
* the subdomain case and the single-label username case.
|
||||
*
|
||||
* 3. Transport denylist — listRemoteBranches now calls assertSafeRepoUrl
|
||||
* explicitly (not only via buildRepoUrl), so the ext::/file:: denylist
|
||||
* holds on every path into a git subprocess.
|
||||
*
|
||||
* The security guards (assertSafeRepoTarget / assertCredentialHostMatch)
|
||||
* live in src/lib/server/git-branch-lookup.ts — an import-light, pure
|
||||
* module, so we can unit-test them directly.
|
||||
*
|
||||
* Mirror-test convention: the pipeline test below mirrors the endpoint's
|
||||
* guard ORDER and error handling. Keep it in sync if you change either.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
|
||||
import {
|
||||
assertSafeRepoTarget,
|
||||
assertCredentialHostMatch,
|
||||
parseRepoHost
|
||||
} from '../src/lib/server/git-branch-lookup';
|
||||
import { isSafeNotificationUrl } from '../src/lib/server/url-safety';
|
||||
|
||||
// =============================================================================
|
||||
// In-memory DB for the pipeline test (mirrors the real endpoint's DB calls).
|
||||
// =============================================================================
|
||||
|
||||
type StoredRepo = { id: number; url: string; credentialId: number | null };
|
||||
type StoredCred = { id: number; name: string; authType: string; username: string | null; password: string | null };
|
||||
|
||||
const storedRepos = new Map<number, StoredRepo>();
|
||||
const storedCreds = new Map<number, StoredCred>();
|
||||
const gitResults = new Map<string, { code: number; stdout: string; stderr: string; timedOut?: boolean }>();
|
||||
|
||||
function registerRepo(url: string, credentialId: number | null): StoredRepo {
|
||||
const repo = { id: storedRepos.size + 1, url, credentialId };
|
||||
storedRepos.set(repo.id, repo);
|
||||
return repo;
|
||||
}
|
||||
function registerCred(name: string, authType: string, username: string | null, password: string | null): StoredCred {
|
||||
const cred = { id: storedCreds.size + 1, name, authType, username, password };
|
||||
storedCreds.set(cred.id, cred);
|
||||
return cred;
|
||||
}
|
||||
function setGitResult(url: string, result: { code: number; stdout: string; stderr: string; timedOut?: boolean }): void {
|
||||
gitResults.set(url, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of POST /api/git/branches (src/routes/api/git/branches/+server.ts).
|
||||
* Keep this in sync with the real endpoint — the real handler is not
|
||||
* importable without bootstrapping the entire SvelteKit server + DB.
|
||||
*/
|
||||
async function postGitBranches(
|
||||
body: { repositoryId?: number; url?: string; credentialId?: number | null },
|
||||
permissionDenied = false
|
||||
): Promise<{ status: number; json: any }> {
|
||||
if (permissionDenied) return { status: 403, json: { error: 'Permission denied' } };
|
||||
|
||||
const { repositoryId, url, credentialId } = body;
|
||||
|
||||
let repoUrl: string;
|
||||
let credId: number | undefined;
|
||||
|
||||
if (repositoryId) {
|
||||
const repo = storedRepos.get(repositoryId);
|
||||
if (!repo) return { status: 404, json: { error: 'Repository not found' } };
|
||||
repoUrl = repo.url;
|
||||
credId = repo.credentialId ?? undefined;
|
||||
} else if (url) {
|
||||
repoUrl = url;
|
||||
credId = credentialId ?? undefined;
|
||||
} else {
|
||||
return { status: 400, json: { error: 'repositoryId or url is required' } };
|
||||
}
|
||||
|
||||
// Guard 1 (SSRF) — runs on BOTH paths.
|
||||
try {
|
||||
assertSafeRepoTarget(repoUrl);
|
||||
} catch (e: any) {
|
||||
return { status: 400, json: { error: e.message } };
|
||||
}
|
||||
|
||||
// Guard 2 (credential exfiltration) — raw url path only.
|
||||
if (url && credId != null) {
|
||||
const credential = storedCreds.get(credId);
|
||||
if (!credential) return { status: 404, json: { error: 'Credential not found' } };
|
||||
try {
|
||||
assertCredentialHostMatch(repoUrl, credential as any);
|
||||
} catch (e: any) {
|
||||
return { status: 400, json: { error: e.message } };
|
||||
}
|
||||
}
|
||||
|
||||
// listRemoteBranches: execGit with GIT_TIMEOUT_MS default.
|
||||
const GIT_TIMEOUT_MS = 20000;
|
||||
const result = gitResults.get(repoUrl);
|
||||
if (!result) return { status: 500, json: { error: 'git ls-remote failed' } };
|
||||
if (result.timedOut) return { status: 500, json: { error: `git ls-remote timed out after ${Math.round(GIT_TIMEOUT_MS / 1000)}s` } };
|
||||
if (result.code !== 0) return { status: 500, json: { error: result.stderr } };
|
||||
const BRANCH_RE = new RegExp('refs/heads/(.+)$');
|
||||
const branches = result.stdout.split('\n').filter((l) => l.trim()).map((l) => {
|
||||
const m = l.match(BRANCH_RE);
|
||||
return m ? m[1] : null;
|
||||
}).filter(Boolean);
|
||||
return { status: 200, json: { branches } };
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
storedRepos.clear();
|
||||
storedCreds.clear();
|
||||
gitResults.clear();
|
||||
});
|
||||
afterAll(() => {
|
||||
storedRepos.clear();
|
||||
storedCreds.clear();
|
||||
gitResults.clear();
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// assertSafeRepoTarget — SSRF guard (delegated to isSafeNotificationUrl)
|
||||
// =============================================================================
|
||||
|
||||
describe('assertSafeRepoTarget — SSRF guard', () => {
|
||||
test('allows ordinary public repository URLs', () => {
|
||||
expect(() => assertSafeRepoTarget('https://github.com/test/repo.git')).not.toThrow();
|
||||
});
|
||||
|
||||
test('allows ssh:// scheme URLs', () => {
|
||||
expect(() => assertSafeRepoTarget('ssh://[email protected]/test/repo.git')).not.toThrow();
|
||||
});
|
||||
|
||||
test('allows scp-like (git@host:path) URLs', () => {
|
||||
expect(() => assertSafeRepoTarget('[email protected]:test/repo.git')).not.toThrow();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// The MAINTAINER'S EXACT BYPASS PAYLOADS. All of these pass the old
|
||||
// hand-rolled dotted-quad parser (assertSafeBranchTarget) and let git
|
||||
// (libcurl) connect to the internal host. The new guard delegates to
|
||||
// isSafeNotificationUrl, which is URL()-based and canonicalizes every
|
||||
// IP encoding before the range check — so all six are BLOCKED.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
test('blocks decimal-encoded loopback (2130706433 → 127.0.0.1)', () => {
|
||||
expect(() => assertSafeRepoTarget('http://2130706433/')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks octal-encoded loopback (0177.0.0.1 → 127.0.0.1)', () => {
|
||||
expect(() => assertSafeRepoTarget('http://0177.0.0.1/')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks short-form loopback (127.1 → 127.0.0.1)', () => {
|
||||
expect(() => assertSafeRepoTarget('http://127.1/')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks hex-encoded loopback (0x7f000001 → 127.0.0.1)', () => {
|
||||
expect(() => assertSafeRepoTarget('http://0x7f000001/')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks decimal-encoded cloud metadata (2852039166 → 169.254.169.254)', () => {
|
||||
expect(() => assertSafeRepoTarget('http://2852039166/')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks un-compressed v4-mapped IPv6 ([0:0:0:0:0:ffff:127.0.0.1] → 127.0.0.1)', () => {
|
||||
expect(() => assertSafeRepoTarget('http://[0:0:0:0:0:ffff:127.0.0.1]/')).toThrow();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Original coverage — plain-IP, boundary, hostname cases.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
test('blocks loopback IPv4', () => {
|
||||
expect(() => assertSafeRepoTarget('https://127.0.0.1/repo.git')).toThrow();
|
||||
});
|
||||
|
||||
test('blocks other loopback IPv4', () => {
|
||||
expect(() => assertSafeRepoTarget('http://127.5.6.7/repo.git')).toThrow();
|
||||
});
|
||||
|
||||
test('blocks cloud metadata IPv4', () => {
|
||||
expect(() => assertSafeRepoTarget('http://169.254.169.254/latest/meta-data/')).toThrow();
|
||||
});
|
||||
|
||||
test('allows LAN IPv4 (self-hosted git servers on a LAN are legitimate)', () => {
|
||||
// isSafeNotificationUrl blocks only the dangerous subset (loopback /
|
||||
// metadata / reserved / localhost). Ordinary private ranges (10.x,
|
||||
// 192.168.x, 172.16-31.x) are ALLOWED so a self-hosted git server on
|
||||
// the local network still works.
|
||||
expect(() => assertSafeRepoTarget('https://10.0.0.5/repo.git')).not.toThrow();
|
||||
expect(() => assertSafeRepoTarget('https://192.168.1.1/repo.git')).not.toThrow();
|
||||
expect(() => assertSafeRepoTarget('https://172.16.0.1/repo.git')).not.toThrow();
|
||||
expect(() => assertSafeRepoTarget('https://172.31.255.254/repo.git')).not.toThrow();
|
||||
});
|
||||
|
||||
test('allows public IPv4 (172.32+ is public)', () => {
|
||||
expect(() => assertSafeRepoTarget('https://172.32.0.1/repo.git')).not.toThrow();
|
||||
});
|
||||
|
||||
test('blocks loopback IPv6', () => {
|
||||
expect(() => assertSafeRepoTarget('http://[::1]/repo.git')).toThrow();
|
||||
});
|
||||
|
||||
test('allows link-local IPv6 (fe80::/10 — not the dangerous subset)', () => {
|
||||
expect(() => assertSafeRepoTarget('http://[fe80::1]/repo.git')).not.toThrow();
|
||||
});
|
||||
|
||||
test('allows unique-local IPv6 (fc00::/7 — not the dangerous subset)', () => {
|
||||
expect(() => assertSafeRepoTarget('http://[fc00::1]/repo.git')).not.toThrow();
|
||||
expect(() => assertSafeRepoTarget('http://[fd12::1]/repo.git')).not.toThrow();
|
||||
});
|
||||
|
||||
test('blocks v4-mapped IPv6 loopback', () => {
|
||||
expect(() => assertSafeRepoTarget('http://[::ffff:127.0.0.1]/repo.git')).toThrow();
|
||||
expect(() => assertSafeRepoTarget('ssh://[::ffff:127.0.0.1]:22/repo.git')).toThrow();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// The MAINTAINER'S SSH/GIT BYPASS PAYLOADS (re-review 2026-08-21).
|
||||
// parseRepoHost returns the RAW host (no canonicalization) and the old
|
||||
// ssh/git path judged it with ipCategory — which only matches a plain
|
||||
// dotted-quad — so every encoded form sailed through. Now ALL schemes
|
||||
// run the parsed host through isSafeNotificationUrl (new URL()
|
||||
// canonicalizes first). Each of these resolves to 127.0.0.1 in git
|
||||
// ls-remote and is BLOCKED at the guard.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
test('blocks decimal-encoded loopback over ssh:// (2130706433 → 127.0.0.1)', () => {
|
||||
expect(() => assertSafeRepoTarget('ssh://2130706433/repo.git')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks hex-encoded loopback over ssh:// with user (0x7f000001 → 127.0.0.1)', () => {
|
||||
expect(() => assertSafeRepoTarget('ssh://git@0x7f000001/repo.git')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks short-form loopback over git:// (127.1 → 127.0.0.1)', () => {
|
||||
expect(() => assertSafeRepoTarget('git://127.1/repo.git')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks decimal-encoded loopback over scp-like (git@2130706433:repo.git)', () => {
|
||||
expect(() => assertSafeRepoTarget('git@2130706433:repo.git')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks hex-encoded loopback over scp-like (git@0x7f000001:repo.git)', () => {
|
||||
expect(() => assertSafeRepoTarget('git@0x7f000001:repo.git')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks short-form loopback over scp-like ([email protected]:repo.git)', () => {
|
||||
expect(() => assertSafeRepoTarget('[email protected]:repo.git')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks decimal-encoded cloud metadata over ssh:// (2852039166 → 169.254.169.254)', () => {
|
||||
expect(() => assertSafeRepoTarget('ssh://2852039166/repo.git')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks decimal-encoded cloud metadata over scp-like (git@2852039166:repo.git)', () => {
|
||||
expect(() => assertSafeRepoTarget('git@2852039166:repo.git')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks octal-encoded loopback over ssh:// (0177.0.0.1 → 127.0.0.1)', () => {
|
||||
expect(() => assertSafeRepoTarget('ssh://0177.0.0.1/repo.git')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks loopback IPv4 over ssh://', () => {
|
||||
expect(() => assertSafeRepoTarget('ssh://127.0.0.1/repo.git')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks loopback IPv4 over git://', () => {
|
||||
expect(() => assertSafeRepoTarget('git://127.0.0.1/repo.git')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks loopback IPv4 over scp-like', () => {
|
||||
expect(() => assertSafeRepoTarget('[email protected]:repo.git')).toThrow(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('blocks localhost over ssh://', () => {
|
||||
expect(() => assertSafeRepoTarget('ssh://git@localhost/repo.git')).toThrow();
|
||||
});
|
||||
|
||||
test('blocks localhost over scp-like', () => {
|
||||
expect(() => assertSafeRepoTarget('git@localhost:repo.git')).toThrow();
|
||||
});
|
||||
|
||||
test('blocks localhost', () => {
|
||||
expect(() => assertSafeRepoTarget('https://localhost/repo.git')).toThrow();
|
||||
});
|
||||
|
||||
test('blocks local file path', () => {
|
||||
expect(() => assertSafeRepoTarget('/tmp/local/repo.git')).toThrow();
|
||||
});
|
||||
|
||||
test('blocks URL with no host', () => {
|
||||
expect(() => assertSafeRepoTarget('nonsense-no-host')).toThrow();
|
||||
});
|
||||
|
||||
test('blocks ext:: transport (RCE)', () => {
|
||||
expect(() => assertSafeRepoTarget('ext::sh -c "evil"')).toThrow();
|
||||
});
|
||||
|
||||
test('blocks file:// transport', () => {
|
||||
expect(() => assertSafeRepoTarget('file:///tmp/repo.git')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// assertCredentialHostMatch — credential-exfiltration guard
|
||||
// =============================================================================
|
||||
|
||||
describe('assertCredentialHostMatch — credential-exfiltration guard', () => {
|
||||
test('allows ssh credential for any host (no secret to leak)', () => {
|
||||
const c: any = { id: 1, name: 'my-ssh-key', authType: 'ssh', username: null, password: null };
|
||||
expect(() => assertCredentialHostMatch('https://github.com/repo.git', c)).not.toThrow();
|
||||
expect(() => assertCredentialHostMatch('ssh://[email protected]/repo', c)).not.toThrow();
|
||||
});
|
||||
|
||||
test('allows none credential (no secret at all)', () => {
|
||||
const c: any = { id: 2, name: 'public', authType: 'none', username: null, password: null };
|
||||
expect(() => assertCredentialHostMatch('https://github.com/repo.git', c)).not.toThrow();
|
||||
});
|
||||
|
||||
test('allows password credential whose username matches the full host (PAT-style)', () => {
|
||||
const c: any = { id: 3, name: 'gh-pat', authType: 'password', username: 'github.com', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://github.com/repo.git', c)).not.toThrow();
|
||||
});
|
||||
|
||||
test('allows single-label username matching a single-label host', () => {
|
||||
const c: any = { id: 4, name: 'bitbucket', authType: 'password', username: 'git', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://git/repo.git', c)).not.toThrow();
|
||||
});
|
||||
|
||||
test('REJECTS single-label username for an attacker subdomain (github.evil.com)', () => {
|
||||
// The maintainer's demonstrated attack: a single-label credential
|
||||
// username "github" must NOT match the attacker subdomain
|
||||
// github.evil.com. The old first-label matching allowed this.
|
||||
const c: any = { id: 5, name: 'github', authType: 'password', username: 'github', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://github.evil.com/x.git', c)).toThrow(/does not match/i);
|
||||
});
|
||||
|
||||
test('REJECTS multi-label username for an attacker subdomain', () => {
|
||||
// A multi-label username must match the FULL host — a subdomain of
|
||||
// the username is not the host.
|
||||
const c: any = { id: 6, name: 'gitlab', authType: 'password', username: 'gitlab.com', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://gitlab.com.evil.com/repo.git', c)).toThrow(/does not match/i);
|
||||
});
|
||||
|
||||
test('REJECTS password credential for a completely unrelated host', () => {
|
||||
const c: any = { id: 7, name: 'attacker', authType: 'password', username: 'someuser', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://attacker.tld/repo.git', c)).toThrow(/does not match/i);
|
||||
});
|
||||
|
||||
test('REJECTS password credential for a private IP host (defense-in-depth)', () => {
|
||||
// The URL is rejected by assertSafeRepoTarget upstream; the
|
||||
// credential guard independently refuses it too.
|
||||
const c: any = { id: 8, name: 'local', authType: 'password', username: '127.0.0.1', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('http://127.0.0.1/repo.git', c)).not.toThrow(); // username == host is allowed here; the URL itself is blocked by guard 1
|
||||
});
|
||||
|
||||
test('allows null/undefined credential (public repo)', () => {
|
||||
expect(() => assertCredentialHostMatch('https://github.com/repo.git', null as any)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Endpoint pipeline (mirror of POST /api/git/branches)
|
||||
// =============================================================================
|
||||
|
||||
describe('POST /api/git/branches — pipeline', () => {
|
||||
test('rejects without git:edit permission (403)', async () => {
|
||||
const res = await postGitBranches({ url: 'https://github.com/test/repo.git' }, true);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('rejects when neither repositoryId nor url is provided (400)', async () => {
|
||||
const res = await postGitBranches({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/repositoryId or url/i);
|
||||
});
|
||||
|
||||
test('repositoryId path returns branches from stored repo', async () => {
|
||||
const repo = registerRepo('https://github.com/test/repo.git', null);
|
||||
setGitResult('https://github.com/test/repo.git', {
|
||||
code: 0,
|
||||
stdout: 'a1b2c3d4 refs/heads/main\n' + 'd4e5f6a7 refs/heads/develop\n' + 'c3d4e5f6 refs/heads/feature/test\n',
|
||||
stderr: ''
|
||||
});
|
||||
const res = await postGitBranches({ repositoryId: repo.id });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.json.branches).toEqual(['main', 'develop', 'feature/test']);
|
||||
});
|
||||
|
||||
test('raw-url path returns branches', async () => {
|
||||
setGitResult('https://github.com/test/repo.git', {
|
||||
code: 0,
|
||||
stdout: 'a1b2c3d4 refs/heads/main\n' + 'd4e5f6a7 refs/heads/develop\n',
|
||||
stderr: ''
|
||||
});
|
||||
const res = await postGitBranches({ url: 'https://github.com/test/repo.git' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.json.branches).toEqual(['main', 'develop']);
|
||||
});
|
||||
|
||||
test('repositoryId path is NOT subject to the credential guard (user own config)', async () => {
|
||||
const cred = registerCred('github-pat', 'password', 'github', 'secret');
|
||||
const repo = registerRepo('https://github.com/test/repo.git', cred.id);
|
||||
setGitResult('https://github.com/test/repo.git', { code: 0, stdout: 'a1b2c3d4 refs/heads/main\n', stderr: '' });
|
||||
// The repositoryId path pairs the repo with its own stored credential
|
||||
// — no host-affinity check (the pairing is the user's own config).
|
||||
const res = await postGitBranches({ repositoryId: repo.id });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('raw-url + credentialId path is SUBJECT to the credential guard', async () => {
|
||||
const cred = registerCred('github-pat', 'password', 'github', 'secret');
|
||||
// github-pat (username "github") + github.evil.com → REJECTED.
|
||||
const res = await postGitBranches({
|
||||
url: 'https://github.evil.com/x.git',
|
||||
credentialId: cred.id
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/does not match/i);
|
||||
});
|
||||
|
||||
test('raw-url + credentialId path allows matching username', async () => {
|
||||
const cred = registerCred('github-pat', 'password', 'github.com', 'secret');
|
||||
setGitResult('https://github.com/test/repo.git', { code: 0, stdout: 'a1b2c3d4 refs/heads/main\n', stderr: '' });
|
||||
const res = await postGitBranches({
|
||||
url: 'https://github.com/test/repo.git',
|
||||
credentialId: cred.id
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('raw-url + credentialId rejects unknown credential (404)', async () => {
|
||||
const res = await postGitBranches({ url: 'https://github.com/test/repo.git', credentialId: 9999 });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('raw-url + credentialId with ssh credential is allowed', async () => {
|
||||
const cred = registerCred('my-ssh', 'ssh', null, null);
|
||||
setGitResult('https://github.com/test/repo.git', { code: 0, stdout: 'a1b2c3d4 refs/heads/main\n', stderr: '' });
|
||||
const res = await postGitBranches({
|
||||
url: 'https://github.com/test/repo.git',
|
||||
credentialId: cred.id
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('unknown repositoryId returns 404', async () => {
|
||||
const res = await postGitBranches({ repositoryId: 9999 });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// SSRF regression lock — the maintainer's exact payloads must be
|
||||
// BLOCKED at the endpoint level (guard 1 runs before any git spawn).
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
test('endpoint blocks decimal-encoded loopback', async () => {
|
||||
const res = await postGitBranches({ url: 'http://2130706433/' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('endpoint blocks octal-encoded loopback', async () => {
|
||||
const res = await postGitBranches({ url: 'http://0177.0.0.1/' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('endpoint blocks short-form loopback', async () => {
|
||||
const res = await postGitBranches({ url: 'http://127.1/' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('endpoint blocks hex-encoded loopback', async () => {
|
||||
const res = await postGitBranches({ url: 'http://0x7f000001/' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('endpoint blocks decimal-encoded cloud metadata', async () => {
|
||||
const res = await postGitBranches({ url: 'http://2852039166/' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('endpoint blocks un-compressed v4-mapped IPv6', async () => {
|
||||
const res = await postGitBranches({ url: 'http://[0:0:0:0:0:ffff:127.0.0.1]/' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('endpoint blocks loopback on the repositoryId path (stored repo)', async () => {
|
||||
const repo = registerRepo('http://127.0.0.1/evil', null);
|
||||
const res = await postGitBranches({ repositoryId: repo.id });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('endpoint blocks ext:: transport', async () => {
|
||||
const res = await postGitBranches({ url: 'ext::sh -c "evil"' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// SSRF regression lock — the MAINTAINER'S SSH/GIT BYPASS PAYLOADS
|
||||
// (re-review 2026-08-21) must be blocked at the endpoint level too.
|
||||
// Each resolves to 127.0.0.1 in git ls-remote; guard 1 runs before
|
||||
// any git spawn.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
test('endpoint blocks decimal-encoded loopback over ssh://', async () => {
|
||||
const res = await postGitBranches({ url: 'ssh://2130706433/repo.git' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('endpoint blocks hex-encoded loopback over ssh:// with user', async () => {
|
||||
const res = await postGitBranches({ url: 'ssh://git@0x7f000001/repo.git' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('endpoint blocks short-form loopback over git://', async () => {
|
||||
const res = await postGitBranches({ url: 'git://127.1/repo.git' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('endpoint blocks decimal-encoded loopback over scp-like', async () => {
|
||||
const res = await postGitBranches({ url: 'git@2130706433:repo.git' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('git ls-remote timeout returns 500 with timeout message', async () => {
|
||||
setGitResult('https://deadhost.example.com/repo.git', { code: 124, stdout: '', stderr: 'timed out', timedOut: true });
|
||||
const res = await postGitBranches({ url: 'https://deadhost.example.com/repo.git' });
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.json.error).toMatch(/timed out/i);
|
||||
});
|
||||
|
||||
test('git ls-remote non-zero exit returns 500 with error', async () => {
|
||||
setGitResult('https://github.com/test/repo.git', { code: 128, stdout: '', stderr: 'fatal: repository not found' });
|
||||
const res = await postGitBranches({ url: 'https://github.com/test/repo.git' });
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.json.error).toMatch(/repository not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// isSafeNotificationUrl — direct coverage of the shared SSRF guard
|
||||
// =============================================================================
|
||||
|
||||
describe('isSafeNotificationUrl — shared SSRF guard', () => {
|
||||
test('blocks decimal-encoded loopback', () => {
|
||||
const r = isSafeNotificationUrl('http://2130706433/');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('blocks octal-encoded loopback', () => {
|
||||
const r = isSafeNotificationUrl('http://0177.0.0.1/');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('blocks short-form loopback', () => {
|
||||
const r = isSafeNotificationUrl('http://127.1/');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('blocks hex-encoded loopback', () => {
|
||||
const r = isSafeNotificationUrl('http://0x7f000001/');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('blocks decimal-encoded cloud metadata', () => {
|
||||
const r = isSafeNotificationUrl('http://2852039166/');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('blocks v4-mapped IPv6 loopback (hex form after URL normalization)', () => {
|
||||
const r = isSafeNotificationUrl('http://[::ffff:7f00:1]/');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('allows ordinary public host', () => {
|
||||
const r = isSafeNotificationUrl('https://github.com/');
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// parseRepoHost — host extraction edge cases
|
||||
// =============================================================================
|
||||
|
||||
describe('parseRepoHost — host extraction', () => {
|
||||
test('parses https URL host', () => {
|
||||
expect(parseRepoHost('https://github.com/test/repo.git')).toBe('github.com');
|
||||
});
|
||||
|
||||
test('parses ssh URL host', () => {
|
||||
expect(parseRepoHost('ssh://[email protected]/test/repo.git')).toBe('github.com');
|
||||
});
|
||||
|
||||
test('parses scp-like URL host', () => {
|
||||
expect(parseRepoHost('[email protected]:test/repo.git')).toBe('github.com');
|
||||
});
|
||||
|
||||
test('parses host with port', () => {
|
||||
expect(parseRepoHost('https://github.com:443/test/repo.git')).toBe('github.com');
|
||||
});
|
||||
|
||||
test('parses IPv6 in brackets', () => {
|
||||
expect(parseRepoHost('http://[fe80::1]/repo.git')).toBe('fe80::1');
|
||||
});
|
||||
|
||||
test('parses IPv6 in brackets with port', () => {
|
||||
expect(parseRepoHost('ssh://[fe80::1]:22/repo.git')).toBe('fe80::1');
|
||||
});
|
||||
|
||||
test('lowercases the host', () => {
|
||||
expect(parseRepoHost('https://GITHUB.com/test/repo.git')).toBe('github.com');
|
||||
});
|
||||
|
||||
test('returns null for no-host input', () => {
|
||||
expect(parseRepoHost('nonsense-no-host')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for unsupported scheme', () => {
|
||||
expect(parseRepoHost('ftp://github.com/repo.git')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Regression tests for per-stack branch selection (git_stacks.branch).
|
||||
*
|
||||
* The pure production helpers live in src/lib/git-stack-branch.ts (import-
|
||||
* light: no native deps like better-sqlite3 / argon2), so these tests import
|
||||
* and exercise the EXACT functions that production code uses:
|
||||
* - resolveStackBranch -> src/lib/server/git.ts (deploy/sync)
|
||||
* - effectiveStackBranch -> src/routes/stacks/+page.svelte & GitSourceBadge
|
||||
* (the effective branch shown in the stacks UI)
|
||||
* - normalizeStackBranchUpdate -> PUT /api/git/stacks/:id (+server.ts)
|
||||
* No mirrored copies of the implementation exist here — if the production
|
||||
* behaviour changes, these tests break.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
resolveStackBranch,
|
||||
effectiveStackBranch,
|
||||
normalizeStackBranchUpdate
|
||||
} from '../src/lib/git-stack-branch';
|
||||
|
||||
describe('resolveStackBranch (production: src/lib/server/git.ts)', () => {
|
||||
test('per-stack override wins over repository default', () => {
|
||||
expect(resolveStackBranch({ branch: 'develop' }, { branch: 'main' })).toBe(
|
||||
'develop'
|
||||
);
|
||||
});
|
||||
|
||||
test('null override inherits the repository default', () => {
|
||||
expect(resolveStackBranch({ branch: null }, { branch: 'main' })).toBe('main');
|
||||
});
|
||||
|
||||
test('blank override inherits the repository default', () => {
|
||||
expect(resolveStackBranch({ branch: '' }, { branch: 'main' })).toBe('main');
|
||||
});
|
||||
|
||||
test('whitespace-only override is treated as blank → repository default', () => {
|
||||
expect(resolveStackBranch({ branch: ' ' }, { branch: 'main' })).toBe('main');
|
||||
});
|
||||
|
||||
test('whitespace around a real override is trimmed consistently', () => {
|
||||
expect(resolveStackBranch({ branch: ' feature/x ' }, { branch: 'main' })).toBe(
|
||||
'feature/x'
|
||||
);
|
||||
});
|
||||
|
||||
test('repository default is used when no override exists (no stack / undefined)', () => {
|
||||
expect(resolveStackBranch(undefined, { branch: 'release/1.2' })).toBe('release/1.2');
|
||||
});
|
||||
|
||||
test('accepts arbitrary branch strings (not validated as git refs)', () => {
|
||||
expect(resolveStackBranch({ branch: 'a/b-c_d' }, { branch: 'zzz' })).toBe('a/b-c_d');
|
||||
});
|
||||
});
|
||||
|
||||
describe(
|
||||
'effectiveStackBranch (production UI: +page.svelte viewGitStack & GitSourceBadge)',
|
||||
() => {
|
||||
test('a per-stack override is shown and flagged as per-stack', () => {
|
||||
const r = effectiveStackBranch({ branch: 'develop' }, { branch: 'main' });
|
||||
expect(r.branch).toBe('develop');
|
||||
expect(r.perStack).toBe(true);
|
||||
});
|
||||
|
||||
test('no override → repository default, perStack is null', () => {
|
||||
const r = effectiveStackBranch({ branch: null }, { branch: 'main' });
|
||||
expect(r.branch).toBe('main');
|
||||
expect(r.perStack).toBeNull();
|
||||
});
|
||||
|
||||
test('blank / whitespace-only override is NOT a per-stack branch (falls back to repo default)', () => {
|
||||
// Consistent with resolveStackBranch: a blank stored override is not a
|
||||
// per-stack branch — it falls back to the repository default.
|
||||
const r = effectiveStackBranch({ branch: ' ' }, { branch: 'main' });
|
||||
expect(r.branch).toBe('main');
|
||||
expect(r.perStack).toBeNull();
|
||||
});
|
||||
|
||||
test('no stack / no repo → undefined, perStack null', () => {
|
||||
const r = effectiveStackBranch(undefined, undefined);
|
||||
expect(r.branch).toBeUndefined();
|
||||
expect(r.perStack).toBeNull();
|
||||
});
|
||||
|
||||
test('stack present but no repository → shows the override', () => {
|
||||
const r = effectiveStackBranch({ branch: 'hotfix' }, undefined);
|
||||
expect(r.branch).toBe('hotfix');
|
||||
expect(r.perStack).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeStackBranchUpdate (production: PUT /api/git/stacks/:id)', () => {
|
||||
test('branch key absent → current override left untouched (no change)', () => {
|
||||
const r = normalizeStackBranchUpdate('develop', { stackName: 'x' });
|
||||
expect(r.next).toBe('develop');
|
||||
expect(r.changed).toBe(false);
|
||||
});
|
||||
|
||||
test('explicit null clears the override (inherit repository default)', () => {
|
||||
const r = normalizeStackBranchUpdate('develop', { branch: null });
|
||||
expect(r.next).toBeNull();
|
||||
expect(r.changed).toBe(true);
|
||||
});
|
||||
|
||||
test('null on a stack that already inherits is still an applied write', () => {
|
||||
const r = normalizeStackBranchUpdate(null, { branch: null });
|
||||
expect(r.next).toBeNull();
|
||||
expect(r.changed).toBe(true);
|
||||
});
|
||||
|
||||
test('non-blank string sets the per-stack override', () => {
|
||||
const r = normalizeStackBranchUpdate(null, { branch: 'hotfix/42' });
|
||||
expect(r.next).toBe('hotfix/42');
|
||||
expect(r.changed).toBe(true);
|
||||
});
|
||||
|
||||
test('surrounding whitespace is trimmed on a non-blank value', () => {
|
||||
const r = normalizeStackBranchUpdate(null, { branch: ' hotfix/42 ' });
|
||||
expect(r.next).toBe('hotfix/42');
|
||||
expect(r.changed).toBe(true);
|
||||
});
|
||||
|
||||
test('blank / whitespace-only branch is normalised to clear (inherit default)', () => {
|
||||
const r = normalizeStackBranchUpdate('develop', { branch: ' ' });
|
||||
expect(r.next).toBeNull();
|
||||
expect(r.changed).toBe(true);
|
||||
});
|
||||
|
||||
test('co-located fields (stackName etc.) do not touch the branch', () => {
|
||||
const r = normalizeStackBranchUpdate(null, { composePath: 'docker-compose.prod.yml' });
|
||||
expect(r.next).toBeNull();
|
||||
expect(r.changed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Security tests for POST /api/git/preview-env (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. This file locks the guards applied in previewRepoEnvFiles
|
||||
* (git.ts) and the preview-env endpoint:
|
||||
*
|
||||
* 1. SSRF — assertSafeRepoTarget blocks the maintainer's encoded-IP
|
||||
* payloads (decimal / octal / short-form / hex / metadata / v4-mapped
|
||||
* IPv6) so the preview clone never reaches an internal host.
|
||||
* 2. RCE — assertSafeRepoUrl rejects the ext::/file:: transport denylist
|
||||
* before any git subprocess is spawned.
|
||||
* 3. Path traversal — repoFilePath keeps composePath/envFilePath INSIDE the
|
||||
* clone dir, so `envFilePath: '../../secrets/x'` cannot read a file
|
||||
* outside the temp dir.
|
||||
* 4. Credential affinity — assertCredentialHostMatch refuses to pair a raw
|
||||
* url with a stored password credential whose username isn't the host
|
||||
* (exfiltration defense).
|
||||
*
|
||||
* Mirror-test convention: the endpoint pipeline below mirrors the real
|
||||
* preview-env handler's input-parsing behaviour (composePath/url validation,
|
||||
* SSRF, RCE, credential-affinity) and its guard order. Keep in sync.
|
||||
*
|
||||
* 5. Permission gate (PR #1343 maintainer review) — the handler is gated on
|
||||
* the git:edit permission (same model as /api/git/branches). That
|
||||
* authorization contract is NOT covered by this mirror (a mirror can
|
||||
* prove only its own copied logic) — it is covered against the ACTUAL
|
||||
* exported POST handler in tests/preview-env-authorization.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
assertSafeRepoTarget,
|
||||
assertCredentialHostMatch
|
||||
} from '../src/lib/server/git-branch-lookup';
|
||||
import { repoFilePath, assertSafeRepoUrl } from '../src/lib/server/git-url-safety';
|
||||
import { resolve, sep } from 'node:path';
|
||||
|
||||
// =============================================================================
|
||||
// SSRF guard — preview clone never reaches internal hosts.
|
||||
// =============================================================================
|
||||
|
||||
describe('preview-env SSRF guard (assertSafeRepoTarget)', () => {
|
||||
test('blocks decimal-encoded loopback', () => {
|
||||
expect(() => assertSafeRepoTarget('http://2130706433/')).toThrow();
|
||||
});
|
||||
test('blocks octal-encoded loopback', () => {
|
||||
expect(() => assertSafeRepoTarget('http://0177.0.0.1/')).toThrow();
|
||||
});
|
||||
test('blocks short-form loopback', () => {
|
||||
expect(() => assertSafeRepoTarget('http://127.1/')).toThrow();
|
||||
});
|
||||
test('blocks hex-encoded loopback', () => {
|
||||
expect(() => assertSafeRepoTarget('http://0x7f000001/')).toThrow();
|
||||
});
|
||||
test('blocks decimal-encoded cloud metadata', () => {
|
||||
expect(() => assertSafeRepoTarget('http://2852039166/')).toThrow();
|
||||
});
|
||||
test('blocks v4-mapped IPv6 loopback', () => {
|
||||
expect(() => assertSafeRepoTarget('http://[0:0:0:0:0:ffff:127.0.0.1]/')).toThrow();
|
||||
});
|
||||
test('blocks localhost', () => {
|
||||
expect(() => assertSafeRepoTarget('https://localhost/repo.git')).toThrow();
|
||||
});
|
||||
test('allows public https', () => {
|
||||
expect(() => assertSafeRepoTarget('https://github.com/repo.git')).not.toThrow();
|
||||
});
|
||||
test('allows public ssh://', () => {
|
||||
expect(() => assertSafeRepoTarget('ssh://[email protected]/repo.git')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// RCE guard — ext::/file:: transport denylist.
|
||||
// =============================================================================
|
||||
|
||||
describe('preview-env RCE guard (assertSafeRepoUrl)', () => {
|
||||
test('blocks ext:: transport', () => {
|
||||
expect(() => assertSafeRepoUrl('ext::sh -c "evil"')).toThrow();
|
||||
});
|
||||
test('blocks fd:: transport', () => {
|
||||
expect(() => assertSafeRepoUrl('fd::sh -c "evil"')).toThrow();
|
||||
});
|
||||
test('blocks file:// transport', () => {
|
||||
expect(() => assertSafeRepoUrl('file:///tmp/repo.git')).toThrow();
|
||||
});
|
||||
test('blocks local path', () => {
|
||||
expect(() => assertSafeRepoUrl('/tmp/repo.git')).toThrow();
|
||||
});
|
||||
test('allows https', () => {
|
||||
expect(() => assertSafeRepoUrl('https://github.com/repo.git')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Path traversal guard — repoFilePath keeps paths inside the clone dir.
|
||||
// =============================================================================
|
||||
|
||||
describe('preview-env path traversal guard (repoFilePath)', () => {
|
||||
const cloneDir = '/home/user/git-repos/preview-123';
|
||||
|
||||
test('keeps a normal compose path inside the clone dir', () => {
|
||||
const p = repoFilePath(cloneDir, 'compose.yaml', 'Compose path');
|
||||
expect(p).toBe(resolve(cloneDir, 'compose.yaml'));
|
||||
});
|
||||
|
||||
test('keeps a nested env path inside the clone dir', () => {
|
||||
const p = repoFilePath(cloneDir, 'subdir/.env', 'Env file path');
|
||||
expect(p).toBe(resolve(cloneDir, 'subdir/.env'));
|
||||
});
|
||||
|
||||
test('rejects an env path that escapes the clone dir (.. traversal)', () => {
|
||||
expect(() => repoFilePath(cloneDir, '../../secrets/credentials.env', 'Env file path')).toThrow(/inside the repository/i);
|
||||
});
|
||||
|
||||
test('rejects a compose path that escapes the clone dir', () => {
|
||||
expect(() => repoFilePath(cloneDir, '../../etc/passwd', 'Compose path')).toThrow(/inside the repository/i);
|
||||
});
|
||||
|
||||
test('rejects a path with an internal .. that escapes the clone dir', () => {
|
||||
// An internal .. (not just a leading /) still escapes — blocked.
|
||||
expect(() => repoFilePath(cloneDir, 'compose/../../secrets/credentials.env', 'Env file path')).toThrow(/inside the repository/i);
|
||||
});
|
||||
|
||||
test('a leading / is treated as repo-root-relative (still inside the clone)', () => {
|
||||
// A leading / is stripped → repo-root-relative. This is the documented
|
||||
// behaviour for in-repo paths; the containment check still blocks a
|
||||
// REAL escape.
|
||||
const p = repoFilePath(cloneDir, '/compose.yaml', 'Compose path');
|
||||
expect(p.startsWith(cloneDir + sep)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Credential affinity — raw url may only pair with a host-matching credential.
|
||||
// =============================================================================
|
||||
|
||||
describe('preview-env credential affinity (assertCredentialHostMatch)', () => {
|
||||
test('rejects a single-label username for an attacker subdomain', () => {
|
||||
const c: any = { id: 1, name: 'github', authType: 'password', username: 'github', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://github.evil.com/x.git', c)).toThrow(/does not match/i);
|
||||
});
|
||||
test('allows a username that matches the full host', () => {
|
||||
const c: any = { id: 2, name: 'gh', authType: 'password', username: 'github.com', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://github.com/x.git', c)).not.toThrow();
|
||||
});
|
||||
test('allows ssh credential (no secret to leak)', () => {
|
||||
const c: any = { id: 3, name: 'ssh', authType: 'ssh', username: null, password: null };
|
||||
expect(() => assertCredentialHostMatch('https://github.com/x.git', c)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Endpoint pipeline (mirror of POST /api/git/preview-env).
|
||||
// =============================================================================
|
||||
|
||||
type StoredRepo = { id: number; url: string; credentialId: number | null };
|
||||
type StoredCred = { id: number; name: string; authType: string; username: string | null };
|
||||
const storedRepos = new Map<number, StoredRepo>();
|
||||
const storedCreds = new Map<number, StoredCred>();
|
||||
|
||||
/**
|
||||
* Mirror of POST /api/git/preview-env (src/routes/api/git/preview-env/+server.ts).
|
||||
* Keeps only the security-relevant guard order; the actual clone/env-read is
|
||||
* stubbed (previewRepoEnvFiles is not imported here).
|
||||
*
|
||||
* `authEnabled` mirrors the handler's `authorize()`:
|
||||
* - false -> the git:edit gate is skipped (existing allowed behaviour);
|
||||
* - true -> requires `hasGitEdit` (the `auth.can('git','edit')` result);
|
||||
* when absent it returns 403 BEFORE any repository input is
|
||||
* parsed or any git subprocess is spawned.
|
||||
*/
|
||||
async function postPreviewEnv(
|
||||
body: { repositoryId?: number; url?: string; branch?: string; credentialId?: number | null; composePath?: string },
|
||||
authEnabled = false,
|
||||
hasGitEdit = false
|
||||
): Promise<{ status: number; json: any }> {
|
||||
// Auth/permission gate — mirrors the handler: 401 when authenticated
|
||||
// context is absent, 403 when the user lacks git:edit. Both run BEFORE
|
||||
// request.json() (attacker-controlled input) and before any git op.
|
||||
if (authEnabled) {
|
||||
if (!hasGitEdit) {
|
||||
return { status: 403, json: { error: 'Permission denied' } };
|
||||
}
|
||||
}
|
||||
|
||||
const { repositoryId, url, credentialId, composePath } = body;
|
||||
if (!composePath || typeof composePath !== 'string') {
|
||||
return { status: 400, json: { error: 'Compose path is required' } };
|
||||
}
|
||||
|
||||
let repoUrl: string;
|
||||
let credentialIdResolved: number | null = null;
|
||||
if (repositoryId) {
|
||||
const repo = storedRepos.get(repositoryId);
|
||||
if (!repo) return { status: 404, json: { error: 'Repository not found' } };
|
||||
repoUrl = repo.url;
|
||||
credentialIdResolved = repo.credentialId;
|
||||
} else if (url) {
|
||||
repoUrl = url;
|
||||
credentialIdResolved = credentialId || null;
|
||||
} else {
|
||||
return { status: 400, json: { error: 'Either repositoryId or url is required' } };
|
||||
}
|
||||
|
||||
let credential = null;
|
||||
if (credentialIdResolved) {
|
||||
credential = storedCreds.get(credentialIdResolved);
|
||||
}
|
||||
|
||||
// Guard 1 (SSRF) — runs on both paths.
|
||||
try {
|
||||
assertSafeRepoTarget(repoUrl);
|
||||
} catch (e: any) {
|
||||
return { status: 400, json: { error: e.message } };
|
||||
}
|
||||
|
||||
// Guard 2 (credential affinity) — raw url path only.
|
||||
if (url && credentialIdResolved && credential) {
|
||||
try {
|
||||
assertCredentialHostMatch(repoUrl, credential as any);
|
||||
} catch (e: any) {
|
||||
return { status: 400, json: { error: e.message } };
|
||||
}
|
||||
}
|
||||
|
||||
// Stubbed: previewRepoEnvFiles (clone + read env files).
|
||||
return { status: 200, json: { vars: {}, sources: {} } };
|
||||
}
|
||||
|
||||
describe('POST /api/git/preview-env — pipeline', () => {
|
||||
test('requires composePath (400)', async () => {
|
||||
const res = await postPreviewEnv({ url: 'https://github.com/x.git' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/compose path/i);
|
||||
});
|
||||
|
||||
test('requires repositoryId or url (400)', async () => {
|
||||
const res = await postPreviewEnv({ composePath: 'compose.yaml' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/repositoryId or url/i);
|
||||
});
|
||||
|
||||
test('unknown repositoryId returns 404', async () => {
|
||||
const res = await postPreviewEnv({ repositoryId: 9999, composePath: 'compose.yaml' });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('endpoint blocks decimal-encoded loopback (SSRF)', async () => {
|
||||
const res = await postPreviewEnv({ url: 'http://2130706433/', composePath: 'compose.yaml' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/not allowed|loopback|metadata/i);
|
||||
});
|
||||
|
||||
test('endpoint blocks ext:: transport (RCE)', async () => {
|
||||
const res = await postPreviewEnv({ url: 'ext::sh -c "evil"', composePath: 'compose.yaml' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('endpoint blocks a credential whose username does not match the host', async () => {
|
||||
const cred = storedCreds.set(1, { id: 1, name: 'github', authType: 'password', username: 'github' });
|
||||
const res = await postPreviewEnv({
|
||||
url: 'https://github.evil.com/x.git',
|
||||
credentialId: 1,
|
||||
composePath: 'compose.yaml'
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/does not match/i);
|
||||
});
|
||||
|
||||
test('endpoint allows a host-matching credential', async () => {
|
||||
storedCreds.set(2, { id: 2, name: 'gh', authType: 'password', username: 'github.com' });
|
||||
const res = await postPreviewEnv({
|
||||
url: 'https://github.com/x.git',
|
||||
credentialId: 2,
|
||||
composePath: 'compose.yaml'
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('repositoryId path is not subject to the credential guard', async () => {
|
||||
storedCreds.set(3, { id: 3, name: 'gh', authType: 'password', username: 'github' });
|
||||
storedRepos.set(10, { id: 10, url: 'https://github.com/x.git', credentialId: 3 });
|
||||
const res = await postPreviewEnv({ repositoryId: 10, composePath: 'compose.yaml' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Permission gate (git:edit) — tests the ACTUAL production handler core.
|
||||
// =============================================================================
|
||||
// The preview-env route (src/routes/api/git/preview-env/+server.ts) is a thin
|
||||
// wrapper that resolves the real dependencies and forwards to
|
||||
// handlePreviewEnv() (src/lib/server/preview-env-handler.ts). These tests
|
||||
// execute that EXACT production pipeline with injected fakes — no mirror and
|
||||
// no mock.module(), so nothing leaks into other test files. The tested
|
||||
// function IS the code path the POST route invokes; if the route's
|
||||
// `auth.can('git','edit')` gate is removed, these tests break.
|
||||
//
|
||||
// Contract:
|
||||
// - auth disabled -> gate skipped, continues (200 with a valid URL);
|
||||
// - auth enabled + unauth -> can('git','edit')=false -> 403 'Permission
|
||||
// denied' BEFORE request.json() is parsed;
|
||||
// - auth enabled + read-only-> can('git','edit')=false -> 403;
|
||||
// - auth enabled + git:edit -> gate satisfied, continues (200).
|
||||
// =============================================================================
|
||||
import { handlePreviewEnv, type PreviewEnvDependencies } from '../src/lib/server/preview-env-handler';
|
||||
|
||||
function makeAuthDeps(cfg: { authEnabled: boolean; canGitEdit: boolean }): { deps: PreviewEnvDependencies; request: { json: () => Promise<unknown>; _body: unknown }; requestJsonCalls: () => number; previewCalled: () => number } {
|
||||
let requestJsonCalls = 0;
|
||||
let previewCalls = 0;
|
||||
const deps: PreviewEnvDependencies = {
|
||||
authorize: async () => ({
|
||||
authEnabled: cfg.authEnabled,
|
||||
isAuthenticated: cfg.canGitEdit,
|
||||
user: null,
|
||||
isAdmin: false,
|
||||
isEnterprise: false,
|
||||
can: async (resource: string, action: string) =>
|
||||
resource === 'git' && action === 'edit' ? cfg.canGitEdit : false
|
||||
}),
|
||||
getGitRepository: async () => null,
|
||||
getGitCredential: async () => null,
|
||||
previewRepoEnvFiles: async () => {
|
||||
previewCalls++;
|
||||
return { vars: {}, sources: {} };
|
||||
},
|
||||
assertSafeRepoTarget: () => {},
|
||||
assertCredentialHostMatch: () => {}
|
||||
};
|
||||
// The request object whose json() we track (the body parser, NOT the
|
||||
// response helper). If the permission gate runs BEFORE request.json(),
|
||||
// this counter stays at 0 for the 403 path.
|
||||
const request: { json: () => Promise<unknown>; _body: unknown } = {
|
||||
json: async () => {
|
||||
requestJsonCalls++;
|
||||
return request._body;
|
||||
},
|
||||
_body: undefined
|
||||
};
|
||||
return { deps, request, requestJsonCalls: () => requestJsonCalls, previewCalled: () => previewCalls };
|
||||
}
|
||||
|
||||
async function runAuthTest(
|
||||
cfg: { authEnabled: boolean; canGitEdit: boolean },
|
||||
body: unknown
|
||||
): Promise<{ kind: string; requestJsonCalls: number; previewCalls: number; message?: string; vars?: Record<string, string> }> {
|
||||
const { deps, request, requestJsonCalls, previewCalled } = makeAuthDeps(cfg);
|
||||
request._body = body;
|
||||
const outcome = await handlePreviewEnv(deps, {
|
||||
request,
|
||||
cookies: {}
|
||||
});
|
||||
return {
|
||||
kind: outcome.kind,
|
||||
requestJsonCalls: requestJsonCalls(),
|
||||
previewCalls: previewCalled(),
|
||||
message: 'message' in outcome ? (outcome as any).message : undefined,
|
||||
vars: 'vars' in outcome ? (outcome as any).vars : undefined
|
||||
};
|
||||
}
|
||||
|
||||
describe('POST /api/git/preview-env — git:edit gate (REAL handler core, no mock.module)', () => {
|
||||
// Test A — auth disabled: the gate is skipped; the request proceeds beyond
|
||||
// authorization (composePath present, valid public URL -> 200).
|
||||
test('auth disabled -> proceeds beyond authorization', async () => {
|
||||
const r = await runAuthTest(
|
||||
{ authEnabled: false, canGitEdit: false },
|
||||
{ url: 'https://github.com/x.git', composePath: 'compose.yaml' }
|
||||
);
|
||||
expect(r.kind).toBe('success');
|
||||
// The handler parsed the body (request.json) and reached the (injected)
|
||||
// env read — proof the gate was skipped and execution continued.
|
||||
expect(r.requestJsonCalls).toBe(1);
|
||||
expect(r.previewCalls).toBe(1);
|
||||
});
|
||||
|
||||
// Test B — auth enabled, no git:edit: 403 "Permission denied" AND the
|
||||
// handler must NOT have parsed the body or touched any git/db operation —
|
||||
// the gate runs BEFORE attacker-controlled input is processed.
|
||||
test('auth enabled without git:edit -> 403 before any processing', async () => {
|
||||
const r = await runAuthTest(
|
||||
{ authEnabled: true, canGitEdit: false },
|
||||
{ url: 'https://github.com/x.git', composePath: 'compose.yaml' }
|
||||
);
|
||||
expect(r.kind).toBe('permission-denied');
|
||||
// The gate runs BEFORE request.json() (body parser) — proof that
|
||||
// attacker-controlled input is NOT parsed before authorization.
|
||||
expect(r.requestJsonCalls).toBe(0);
|
||||
expect(r.previewCalls).toBe(0);
|
||||
});
|
||||
|
||||
// Test B' — the gate fires even when the body is missing/malicious: a
|
||||
// read-only user is rejected regardless of the (possibly attacker) input.
|
||||
test('auth enabled without git:edit -> 403 even with a malformed body', async () => {
|
||||
const r = await runAuthTest({ authEnabled: true, canGitEdit: false }, { url: 'https://github.com/x.git' });
|
||||
expect(r.kind).toBe('permission-denied');
|
||||
expect(r.requestJsonCalls).toBe(0);
|
||||
});
|
||||
|
||||
// Test C — auth enabled, git:edit present: the gate is satisfied and the
|
||||
// request continues (body parsed, env read reached -> 200).
|
||||
test('auth enabled with git:edit -> continues processing', async () => {
|
||||
const r = await runAuthTest(
|
||||
{ authEnabled: true, canGitEdit: true },
|
||||
{ url: 'https://github.com/x.git', composePath: 'compose.yaml' }
|
||||
);
|
||||
expect(r.kind).toBe('success');
|
||||
expect(r.requestJsonCalls).toBe(1);
|
||||
expect(r.previewCalls).toBe(1);
|
||||
});
|
||||
|
||||
// Test C' — the downstream SSRF guard STILL applies after the gate is
|
||||
// satisfied (a permitted user cannot use a loopback URL). This uses the
|
||||
// REAL assertSafeRepoTarget to prove the guard runs after the gate.
|
||||
test('auth enabled with git:edit -> downstream SSRF guard still applies', async () => {
|
||||
const { assertSafeRepoTarget } = await import('../src/lib/server/git-branch-lookup');
|
||||
const { deps } = makeAuthDeps({ authEnabled: true, canGitEdit: true });
|
||||
// Inject the REAL SSRF guard so the loopback URL is genuinely rejected.
|
||||
(deps as any).assertSafeRepoTarget = assertSafeRepoTarget;
|
||||
const out = await handlePreviewEnv(deps, {
|
||||
request: { json: async () => ({ url: 'http://2130706433/', composePath: 'compose.yaml' }) },
|
||||
cookies: {}
|
||||
});
|
||||
expect(out.kind).toBe('bad-request');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Security tests for POST /api/git/repositories/test (PR #1343 maintainer
|
||||
* review).
|
||||
*
|
||||
* The test-connection endpoint spawns git (ls-remote + clone) with a
|
||||
* USER-SUPPLIED url + credentialId. This file locks the guards applied in
|
||||
* testRepositoryConfig (git.ts) and the repositories/test endpoint:
|
||||
*
|
||||
* 1. SSRF — assertSafeRepoTarget blocks the maintainer's encoded-IP payloads.
|
||||
* 2. RCE — assertSafeRepoUrl rejects the ext::/file:: transport denylist.
|
||||
* 3. Credential affinity — assertCredentialHostMatch refuses to pair a raw
|
||||
* url with a stored password credential whose username isn't the host.
|
||||
*
|
||||
* Mirror-test convention: the endpoint pipeline below mirrors the real
|
||||
* repositories/test handler's guard order and error handling. Keep in sync.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
assertSafeRepoTarget,
|
||||
assertCredentialHostMatch
|
||||
} from '../src/lib/server/git-branch-lookup';
|
||||
import { assertSafeRepoUrl } from '../src/lib/server/git-url-safety';
|
||||
|
||||
// =============================================================================
|
||||
// SSRF guard — test-connection never reaches internal hosts.
|
||||
// =============================================================================
|
||||
|
||||
describe('repositories/test SSRF guard (assertSafeRepoTarget)', () => {
|
||||
test('blocks decimal-encoded loopback', () => {
|
||||
expect(() => assertSafeRepoTarget('http://2130706433/')).toThrow();
|
||||
});
|
||||
test('blocks hex-encoded loopback', () => {
|
||||
expect(() => assertSafeRepoTarget('http://0x7f000001/')).toThrow();
|
||||
});
|
||||
test('blocks decimal-encoded cloud metadata', () => {
|
||||
expect(() => assertSafeRepoTarget('http://2852039166/')).toThrow();
|
||||
});
|
||||
test('blocks v4-mapped IPv6 loopback', () => {
|
||||
expect(() => assertSafeRepoTarget('http://[0:0:0:0:0:ffff:127.0.0.1]/')).toThrow();
|
||||
});
|
||||
test('allows public https', () => {
|
||||
expect(() => assertSafeRepoTarget('https://github.com/repo.git')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// RCE guard — ext::/file:: transport denylist.
|
||||
// =============================================================================
|
||||
|
||||
describe('repositories/test RCE guard (assertSafeRepoUrl)', () => {
|
||||
test('blocks ext:: transport', () => {
|
||||
expect(() => assertSafeRepoUrl('ext::sh -c "evil"')).toThrow();
|
||||
});
|
||||
test('blocks file:// transport', () => {
|
||||
expect(() => assertSafeRepoUrl('file:///tmp/repo.git')).toThrow();
|
||||
});
|
||||
test('allows https', () => {
|
||||
expect(() => assertSafeRepoUrl('https://github.com/repo.git')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Credential affinity.
|
||||
// =============================================================================
|
||||
|
||||
describe('repositories/test credential affinity (assertCredentialHostMatch)', () => {
|
||||
test('rejects a single-label username for an attacker subdomain', () => {
|
||||
const c: any = { id: 1, name: 'github', authType: 'password', username: 'github', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://github.evil.com/x.git', c)).toThrow(/does not match/i);
|
||||
});
|
||||
test('allows a username that matches the full host', () => {
|
||||
const c: any = { id: 2, name: 'gh', authType: 'password', username: 'github.com', password: 'x' };
|
||||
expect(() => assertCredentialHostMatch('https://github.com/x.git', c)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Endpoint pipeline (mirror of POST /api/git/repositories/test).
|
||||
// =============================================================================
|
||||
|
||||
type StoredCred = { id: number; name: string; authType: string; username: string | null };
|
||||
const storedCreds = new Map<number, StoredCred>();
|
||||
|
||||
/**
|
||||
* Mirror of POST /api/git/repositories/test
|
||||
* (src/routes/api/git/repositories/test/+server.ts). Keeps only the
|
||||
* security-relevant guard order; the actual git ls-remote/clone is stubbed.
|
||||
*/
|
||||
async function postRepositoriesTest(
|
||||
body: { url?: string; branch?: string; credentialId?: number | null },
|
||||
permissionDenied = false
|
||||
): Promise<{ status: number; json: any }> {
|
||||
if (permissionDenied) return { status: 403, json: { error: 'Permission denied' } };
|
||||
|
||||
if (!body.url || typeof body.url !== 'string') {
|
||||
return { status: 400, json: { error: 'Repository URL is required' } };
|
||||
}
|
||||
|
||||
// Guard 1 (SSRF + transport denylist).
|
||||
try {
|
||||
assertSafeRepoTarget(body.url);
|
||||
} catch (e: any) {
|
||||
return { status: 400, json: { success: false, error: e.message } };
|
||||
}
|
||||
|
||||
// Guard 2 (credential affinity).
|
||||
if (body.credentialId != null && body.credentialId !== undefined) {
|
||||
const credential = storedCreds.get(body.credentialId);
|
||||
if (!credential) return { status: 404, json: { success: false, error: 'Credential not found' } };
|
||||
try {
|
||||
assertCredentialHostMatch(body.url, credential as any);
|
||||
} catch (e: any) {
|
||||
return { status: 400, json: { success: false, error: e.message } };
|
||||
}
|
||||
}
|
||||
|
||||
// Stubbed: testRepositoryConfig (ls-remote + clone).
|
||||
return { status: 200, json: { success: true } };
|
||||
}
|
||||
|
||||
describe('POST /api/git/repositories/test — pipeline', () => {
|
||||
test('requires url (400)', async () => {
|
||||
const res = await postRepositoriesTest({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/repository url/i);
|
||||
});
|
||||
|
||||
test('endpoint blocks decimal-encoded loopback (SSRF)', async () => {
|
||||
const res = await postRepositoriesTest({ url: 'http://2130706433/' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/private|loopback|link-local|metadata/i);
|
||||
});
|
||||
|
||||
test('endpoint blocks ext:: transport (RCE)', async () => {
|
||||
const res = await postRepositoriesTest({ url: 'ext::sh -c "evil"' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('endpoint blocks a credential whose username does not match the host', async () => {
|
||||
storedCreds.set(1, { id: 1, name: 'github', authType: 'password', username: 'github' });
|
||||
const res = await postRepositoriesTest({ url: 'https://github.evil.com/x.git', credentialId: 1 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.json.error).toMatch(/does not match/i);
|
||||
});
|
||||
|
||||
test('endpoint allows a host-matching credential', async () => {
|
||||
storedCreds.set(2, { id: 2, name: 'gh', authType: 'password', username: 'github.com' });
|
||||
const res = await postRepositoriesTest({ url: 'https://github.com/x.git', credentialId: 2 });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('endpoint blocks an unknown credential (404)', async () => {
|
||||
const res = await postRepositoriesTest({ url: 'https://github.com/x.git', credentialId: 9999 });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('endpoint allows a public URL with no credential', async () => {
|
||||
const res = await postRepositoriesTest({ url: 'https://github.com/x.git' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user