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
+38984
-106
No files matched your search
@@ -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