Files
dockhand-upstream/tests/preview-env-security.test.ts
T
Bartosz S. 28bb291c32 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
2026-08-22 10:20:43 +02:00

429 lines
18 KiB
TypeScript

/**
* 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://git@github.com/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');
});
});