branch selector on git stack, with per-stack branch override

This commit is contained in:
Jarek Krochmalski committed 2026-08-22 16:22:18 +02:00
1 parent 94f8f52f52
commit 68821f4d92
12 files changed
+85 -457

No files matched your search

+7 -74
View File
@@ -14,15 +14,12 @@
* 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.
* SSRF, RCE) and its guard order. Keep in sync.
*
* 5. Permission gate (PR #1343 maintainer review) — the handler is gated on
* 4. 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
@@ -30,10 +27,7 @@
*/
import { describe, expect, test } from 'bun:test';
import {
assertSafeRepoTarget,
assertCredentialHostMatch
} from '../src/lib/server/git-branch-lookup';
import { assertSafeRepoTarget } from '../src/lib/server/git-branch-lookup';
import { repoFilePath, assertSafeRepoUrl } from '../src/lib/server/git-url-safety';
import { resolve, sep } from 'node:path';
@@ -132,33 +126,12 @@ describe('preview-env path traversal guard (repoFilePath)', () => {
});
});
// =============================================================================
// 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).
@@ -185,30 +158,22 @@ async function postPreviewEnv(
}
}
const { repositoryId, url, credentialId, composePath } = body;
const { repositoryId, url, 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);
@@ -216,15 +181,6 @@ async function postPreviewEnv(
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: {} } };
}
@@ -258,30 +214,8 @@ describe('POST /api/git/preview-env — pipeline', () => {
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 });
test('repositoryId path resolves the stored repo URL', async () => {
storedRepos.set(10, { id: 10, url: 'https://github.com/x.git', credentialId: null });
const res = await postPreviewEnv({ repositoryId: 10, composePath: 'compose.yaml' });
expect(res.status).toBe(200);
});
@@ -326,8 +260,7 @@ function makeAuthDeps(cfg: { authEnabled: boolean; canGitEdit: boolean }): { dep
previewCalls++;
return { vars: {}, sources: {} };
},
assertSafeRepoTarget: () => {},
assertCredentialHostMatch: () => {}
assertSafeRepoTarget: () => {}
};
// The request object whose json() we track (the body parser, NOT the
// response helper). If the permission gate runs BEFORE request.json(),