* 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
651 lines
27 KiB
TypeScript
651 lines
27 KiB
TypeScript
/**
|
|
* 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://git@github.com/test/repo.git')).not.toThrow();
|
|
});
|
|
|
|
test('allows scp-like (git@host:path) URLs', () => {
|
|
expect(() => assertSafeRepoTarget('git@github.com: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 (git@127.1:repo.git)', () => {
|
|
expect(() => assertSafeRepoTarget('git@127.1: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('git@127.0.0.1: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://git@internal-git.example.com/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://git@github.com/test/repo.git')).toBe('github.com');
|
|
});
|
|
|
|
test('parses scp-like URL host', () => {
|
|
expect(parseRepoHost('git@github.com: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();
|
|
});
|
|
});
|