mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 06:35:29 +00:00
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:
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user