Files
dockhand-upstream/tests/repositories-test-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

163 lines
6.6 KiB
TypeScript

/**
* 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);
});
});