fix(security): harden authentication and outbound targets (#1877)

* fix(security): harden auth and outbound targets

* fix(security): prevent login lockout and honor trusted schemes
This commit is contained in:
Anso
2026-09-01 20:52:12 +00:00
committed by GitHub
parent 82dca29314
commit 79b86ddcd4
76 changed files with 1457 additions and 212 deletions
+12
View File
@@ -27,6 +27,9 @@ export type TransportFacingCode =
/** Structured failure raised by the native transport; classified below. */
export type TransportFailureReason =
| 'invalid-url'
| 'unsafe-target'
| 'target-unresolved'
| 'ssh-auth-required'
| 'invalid-ref'
| 'git-missing'
| 'git-old'
@@ -52,6 +55,9 @@ interface TransportFailureBase {
*/
export type TransportFailure = TransportFailureBase & (
| { reason: 'invalid-url' }
| { reason: 'unsafe-target' }
| { reason: 'target-unresolved' }
| { reason: 'ssh-auth-required' }
| { reason: 'invalid-ref' }
| { reason: 'git-missing'; stderr?: string }
| { reason: 'git-old'; stderr?: string }
@@ -108,6 +114,12 @@ export function classifyGitFailure(
switch (failure.reason) {
case 'invalid-url':
return { code: 'GIT_ERROR', message: 'Unsupported repository URL. Use https:// or SSH (git@host:org/repo.git or ssh://) without embedded credentials.' };
case 'unsafe-target':
return { code: 'GIT_ERROR', message: 'Repository host is not allowed.' };
case 'target-unresolved':
return { code: 'NETWORK_TIMEOUT', message: `Could not resolve${dest}. Check the repository URL and your network or DNS.` };
case 'ssh-auth-required':
return { code: 'GIT_ERROR', message: 'SSH repository URLs require a deploy key.' };
case 'invalid-ref':
return { code: 'GIT_ERROR', message: 'Unsupported ref name. Use a branch name, a tag name, or a full commit SHA as the remote reports it.' };
case 'git-missing':
+68 -9
View File
@@ -22,6 +22,7 @@ import {
type ParsedRepoUrl,
} from './sshTrust';
import { writeDeployKey, writeKnownHosts } from './sshCredentialFiles';
import { resolveSafeOutboundHostname, UnsafeOutboundTargetError } from '../../utils/outboundTarget';
/**
* Native git transport: every Git operation is an `execFile`-style spawn of
@@ -32,8 +33,8 @@ import { writeDeployKey, writeKnownHosts } from './sshCredentialFiles';
* - `GIT_CONFIG_NOSYSTEM=1` plus an isolated empty HOME/USERPROFILE so the
* operator's ~/.gitconfig (credential helpers, insteadOf rewrites, hooks)
* cannot influence fetches.
* - `protocol.allow=never` with only https re-enabled: no file://, git://,
* ext::, or ssh:// this early in the program.
* - `protocol.allow=never` with only the validated target protocol (HTTPS or
* SSH) re-enabled: file://, git://, and ext:: remain blocked.
* - `core.hooksPath` pointed at an empty directory we own, so repository
* scripts can never run. (A literal /dev/null works on Linux but not
* Windows; an empty dir is portable.)
@@ -293,6 +294,12 @@ function buildEnv(
// An inherited trace flag would widen the log surface with packet
// dumps that can carry URL material.
GIT_TRACE: '',
HTTP_PROXY: '',
HTTPS_PROXY: '',
ALL_PROXY: '',
http_proxy: '',
https_proxy: '',
all_proxy: '',
HOME: homeDir,
};
if (process.platform === 'win32') {
@@ -449,6 +456,7 @@ async function commonArgs(
*/
async function prepareInvocation(
workspaceRoot: string,
target: ResolvedRepoTarget,
repoUrl: string,
token?: string | null,
sshAuth?: ResolveRequest['sshAuth'],
@@ -456,10 +464,18 @@ async function prepareInvocation(
): Promise<{ layout: WorkspaceLayout; env: NodeJS.ProcessEnv; baseArgs: string[]; allowedHost: string | null; caPath: string | null }> {
const layout = await prepareWorkspace(workspaceRoot);
let sshCommand: string | null = null;
if (sshAuth) {
if (target.kind === 'ssh') {
if (!sshAuth) {
throw {
transportFailure: true,
reason: 'ssh-auth-required',
host: target.sshTarget.hostKeyAlias,
hasToken: Boolean(token),
} satisfies TransportFailure;
}
const keyPath = await writeDeployKey(layout.metaDir, sshAuth.privateKey);
const knownPath = await writeKnownHosts(layout.metaDir, sshAuth.knownHostsEntry);
sshCommand = buildSshCommand(keyPath, knownPath);
sshCommand = buildSshCommand(keyPath, knownPath, target.sshTarget);
}
const helperPath = token ? await writeCredentialHelper(layout.metaDir) : null;
const parsed = parseRepoTransportUrl(repoUrl);
@@ -467,7 +483,8 @@ async function prepareInvocation(
? credentialScopeHost(parsed.host)
: null;
const env = buildEnv(layout.homeDir, token, helperPath, sshCommand, allowedHost);
const { args: baseArgs, caPath } = await commonArgs(layout, helperPath, Boolean(sshAuth), caBundlePem);
const { args: commonBaseArgs, caPath } = await commonArgs(layout, helperPath, target.kind === 'ssh', caBundlePem);
const baseArgs = [...commonBaseArgs, ...target.gitArgs];
return { layout, env, baseArgs, allowedHost, caPath };
}
@@ -765,6 +782,7 @@ export async function verifyFastForward(req: {
const hasToken = Boolean(req.token) || Boolean(req.sshAuth);
await ensureBinaryReady(hasToken);
const repo = assertValidRepoUrl(req.repoUrl, hasToken);
const target = await resolveSafeRepoTarget(repo, hasToken);
const host = repoHostLabel(repo);
const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const deadline = Date.now() + timeoutMs;
@@ -775,7 +793,7 @@ export async function verifyFastForward(req: {
}
};
const { env, baseArgs, caPath } = await prepareInvocation(
req.workspaceRoot, req.repoUrl, req.token, req.sshAuth, req.caBundlePem,
req.workspaceRoot, target, req.repoUrl, req.token, req.sshAuth, req.caBundlePem,
);
// Resolved once if the host refuses a redirect, then reused by the deepen
// rounds so they do not each re-walk the same chain.
@@ -988,6 +1006,46 @@ export async function verifyFastForward(req: {
}
}
type ResolvedRepoTarget =
| { kind: 'https'; gitArgs: string[] }
| { kind: 'ssh'; gitArgs: []; sshTarget: { address: string; hostKeyAlias: string } };
async function resolveSafeRepoTarget(repo: ParsedRepoUrl, hasToken: boolean): Promise<ResolvedRepoTarget> {
try {
if (repo.kind === 'https') {
const url = new URL(repo.href);
const [{ address, family }] = await resolveSafeOutboundHostname(url.hostname);
const port = url.port || '443';
const curlAddress = family === 6 ? `[${address}]` : address;
return {
kind: 'https',
gitArgs: [
'-c', 'http.followRedirects=false',
'-c', 'http.proxy=',
'-c', `http.curloptResolve=${url.hostname}:${port}:${curlAddress}`,
],
};
}
const [{ address }] = await resolveSafeOutboundHostname(repo.host);
const hostKeyAlias = repo.port && repo.port !== 22
? `[${repo.host}]:${repo.port}`
: repo.host;
return {
kind: 'ssh',
gitArgs: [],
sshTarget: { address, hostKeyAlias },
};
} catch (error: unknown) {
if (!(error instanceof UnsafeOutboundTargetError)) throw error;
throw {
transportFailure: true,
reason: error.reason === 'blocked' ? 'unsafe-target' : 'target-unresolved',
host: repoHostLabel(repo),
hasToken,
} satisfies TransportFailure;
}
}
export const nativeGitTransport: GitTransport = {
async resolveRef(req: ResolveRequest): Promise<ResolveResult> {
const hasToken = Boolean(req.token) || Boolean(req.sshAuth);
@@ -1003,9 +1061,9 @@ export const nativeGitTransport: GitTransport = {
}
assertValidRef(req.ref, repoHostLabel(repo), hasToken);
const target = await resolveSafeRepoTarget(repo, hasToken);
const { env, baseArgs, caPath } = await prepareInvocation(
req.workspaceRoot, req.repoUrl, req.token, req.sshAuth, req.caBundlePem,
req.workspaceRoot, target, req.repoUrl, req.token, req.sshAuth, req.caBundlePem,
);
const found = await lsRemoteRefs(
repo, req.ref, env, baseArgs,
@@ -1020,6 +1078,7 @@ export const nativeGitTransport: GitTransport = {
const hasToken = Boolean(req.token) || Boolean(req.sshAuth);
await ensureBinaryReady(hasToken);
const repo = assertValidRepoUrl(req.repoUrl, hasToken);
const target = await resolveSafeRepoTarget(repo, hasToken);
assertValidRef(req.ref, repoHostLabel(repo), hasToken);
// The credential scope host is set inside prepareInvocation via
@@ -1027,7 +1086,7 @@ export const nativeGitTransport: GitTransport = {
// credentials for any other host. A refused redirect is resolved and
// approved by the preflight below before any retry.
const { layout, env, baseArgs, caPath } = await prepareInvocation(
req.workspaceRoot, req.repoUrl, req.token, req.sshAuth, req.caBundlePem,
req.workspaceRoot, target, req.repoUrl, req.token, req.sshAuth, req.caBundlePem,
);
const checkout = path.join(req.workspaceRoot, 'repo');
const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;
+33 -20
View File
@@ -68,7 +68,7 @@ export function parseSshUrl(raw: string): ParsedSshRepoUrl | null {
if (pathname === '/' || pathname.includes('..')) return null;
const user = url.username;
const href = port === DEFAULT_SSH_PORT
? `${user}@${url.hostname}:${pathname.slice(1)}`
? `${user}@${url.hostname}:${pathname}`
: `ssh://${user}@${url.hostname}:${port}${pathname}`;
return { href, host: url.hostname, port, pathname };
}
@@ -206,11 +206,11 @@ export interface ScannedHostKey {
line: string;
}
function runSshKeyscan(host: string, port: number): Promise<{ stdout: string; stderr: string; exitCode: number }> {
function runSshKeyscan(address: string, port: number): Promise<{ stdout: string; stderr: string; exitCode: number }> {
return new Promise((resolve, reject) => {
const args = port === DEFAULT_SSH_PORT
? ['-H', host]
: ['-p', String(port), '-H', host];
? [address]
: ['-p', String(port), address];
const child = spawn('ssh-keyscan', args, { windowsHide: true });
let stdout = '';
let stderr = '';
@@ -231,8 +231,8 @@ function runSshKeyscan(host: string, port: number): Promise<{ stdout: string; st
}
/** Fetch host keys from the server without trusting them (probe step only). */
export async function scanHostKeys(host: string, port: number): Promise<ScannedHostKey[]> {
const result = await runSshKeyscan(host, port);
export async function scanHostKeys(host: string, port: number, address: string): Promise<ScannedHostKey[]> {
const result = await runSshKeyscan(address, port);
if (result.exitCode !== 0 && !result.stdout.trim()) {
throw new Error(result.stderr.trim() || 'ssh-keyscan failed');
}
@@ -240,11 +240,13 @@ export async function scanHostKeys(host: string, port: number): Promise<ScannedH
for (const line of result.stdout.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const fingerprint = fingerprintFromKnownHostsLine(trimmed);
if (!fingerprint) continue;
const material = keyMaterialFromKnownHostsLine(trimmed);
const keyType = material?.keyType ?? 'unknown';
keys.push({ keyType, fingerprint, line: trimmed });
if (!material) continue;
const knownHost = port === DEFAULT_SSH_PORT ? host : `[${host}]:${port}`;
const knownHostsLine = `${knownHost} ${material.keyType} ${material.keyBase64}`;
const fingerprint = fingerprintFromKnownHostsLine(knownHostsLine);
if (!fingerprint) continue;
keys.push({ keyType: material.keyType, fingerprint, line: knownHostsLine });
}
if (keys.length === 0) {
throw new Error('No host keys returned from ssh-keyscan');
@@ -256,17 +258,28 @@ export async function scanHostKeys(host: string, port: number): Promise<ScannedH
* Build GIT_SSH_COMMAND / core.sshCommand value enforcing strict host-key
* checking against our per-fetch known_hosts file and a single deploy key.
*/
export function buildSshCommand(keyPath: string, knownHostsPath: string): string {
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
export function buildSshCommand(
keyPath: string,
knownHostsPath: string,
target: { address: string; hostKeyAlias: string },
): string {
const key = keyPath.split(path.sep).join('/');
const known = knownHostsPath.split(path.sep).join('/');
return [
const args = [
'ssh',
'-o', 'BatchMode=yes',
'-o', 'StrictHostKeyChecking=yes',
'-o', 'UserKnownHostsFile=' + known,
'-o', 'IdentitiesOnly=yes',
'-o', 'IdentityAgent=none',
'-F', '/dev/null',
'-i', key,
].join(' ');
'-o BatchMode=yes',
'-o StrictHostKeyChecking=yes',
`-o ${shellQuote(`UserKnownHostsFile=${known}`)}`,
'-o IdentitiesOnly=yes',
'-o IdentityAgent=none',
'-F /dev/null',
`-i ${shellQuote(key)}`,
];
args.push(`-o ${shellQuote(`Hostname=${target.address}`)}`);
args.push(`-o ${shellQuote(`HostKeyAlias=${target.hostKeyAlias}`)}`);
return args.join(' ');
}