From 5b0ed41458e6cb82ee0251d051faf3619119cb35 Mon Sep 17 00:00:00 2001 From: Ender Veiga Bueno Date: Wed, 19 Aug 2026 11:26:41 +0200 Subject: [PATCH] Fix authenticated macOS Git SSH proxying --- src/sandbox/sandbox-utils.ts | 25 ++- src/sandbox/socks5-proxy-command.ts | 157 ++++++++++++++++++ test/sandbox/proxy-env-vars.test.ts | 17 ++ .../socks-authenticated-client.test.ts | 97 +++++++++++ 4 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 src/sandbox/socks5-proxy-command.ts create mode 100644 test/sandbox/socks-authenticated-client.test.ts diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index 93358517..503da555 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -1,6 +1,7 @@ import { homedir } from 'os' import * as path from 'path' import * as fs from 'fs' +import { fileURLToPath } from 'node:url' import { getPlatform } from '../utils/platform.js' import { logForDebugging } from '../utils/debug.js' @@ -82,6 +83,10 @@ function containsGlobCharsForPlatform(p: string): boolean { : containsGlobChars(p) } +function shellDoubleQuote(value: string): string { + return `"${value.replace(/[\\"$`]/g, character => `\\${character}`)}"` +} + /** * Strip the Win32 `\\?\` extended-path prefix so the residue is * a conventional absolute path (drive-letter or UNC) with no `?` @@ -582,11 +587,23 @@ export function generateProxyEnvVars( const sshMuxOverride = '-o ControlMaster=no -o ControlPath=none' const platform = getPlatform() if (platform === 'macos') { - // macOS: use BSD nc SOCKS5 proxy support (-X 5 -x). nc has no SOCKS5 - // auth, so when proxyAuthToken is set, git-over-ssh fails at the SOCKS - // handshake — use git-over-https (HTTP_PROXY carries the credential). + // macOS BSD nc supports SOCKS5 but not username/password auth. Use the + // bundled client when the proxy requires auth; retain nc for the legacy + // unauthenticated case. + const proxyCommand = proxyAuthToken + ? `${shellDoubleQuote(process.execPath)} ${shellDoubleQuote( + path.join( + path.dirname(fileURLToPath(import.meta.url)), + fileURLToPath(import.meta.url).endsWith('.ts') + ? 'socks5-proxy-command.ts' + : 'socks5-proxy-command.js', + ), + )} ${socksProxyPort} ${shellDoubleQuote(userRaw)} ${shellDoubleQuote( + proxyAuthToken, + )} "%h" "%p"` + : `nc -X 5 -x localhost:${socksProxyPort} %h %p` envVars.push( - `GIT_SSH_COMMAND=ssh ${sshMuxOverride} -o ProxyCommand='nc -X 5 -x localhost:${socksProxyPort} %h %p'`, + `GIT_SSH_COMMAND=ssh ${sshMuxOverride} -o ProxyCommand='${proxyCommand}'`, ) } else if (platform === 'linux' && httpProxyPort) { // Linux: use socat HTTP CONNECT via the HTTP proxy bridge. diff --git a/src/sandbox/socks5-proxy-command.ts b/src/sandbox/socks5-proxy-command.ts new file mode 100644 index 00000000..76fa0a82 --- /dev/null +++ b/src/sandbox/socks5-proxy-command.ts @@ -0,0 +1,157 @@ +import { connect, type Socket } from 'node:net' + +const [proxyPortArg, username, password, destinationHost, destinationPortArg] = + process.argv.slice(2) + +function fail(message: string): never { + throw new Error(message) +} + +function required(value: string | undefined, name: string): string { + if (value === undefined || value.length === 0) fail(`missing ${name}`) + return value +} + +const proxyPort = Number(required(proxyPortArg, 'proxy port')) +const destinationPort = Number(required(destinationPortArg, 'destination port')) +const proxyUsername = required(username, 'proxy username') +const proxyPassword = required(password, 'proxy password') +const host = required(destinationHost, 'destination host') + +if (!Number.isInteger(proxyPort) || proxyPort < 1 || proxyPort > 65535) { + fail('invalid proxy port') +} +if ( + !Number.isInteger(destinationPort) || + destinationPort < 1 || + destinationPort > 65535 +) { + fail('invalid destination port') +} +if (Buffer.byteLength(proxyUsername) > 255) fail('proxy username is too long') +if (Buffer.byteLength(proxyPassword) > 255) fail('proxy password is too long') +if (Buffer.byteLength(host) > 255) fail('destination host is too long') + +type ParsedReply = { consumed: number; value: boolean } + +function readReply( + socket: Socket, + parse: (buffer: Buffer) => ParsedReply | undefined, +): Promise { + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0) + + const cleanup = (): void => { + socket.off('data', onData) + socket.off('error', onError) + socket.off('close', onClose) + } + const onError = (error: Error): void => { + cleanup() + reject(error) + } + const onClose = (): void => { + cleanup() + reject(new Error('SOCKS proxy closed during handshake')) + } + const onData = (chunk: Buffer): void => { + buffer = Buffer.concat([buffer, chunk]) + const reply = parse(buffer) + if (reply === undefined) return + cleanup() + const remainder = buffer.subarray(reply.consumed) + if (remainder.length > 0) socket.unshift(remainder) + resolve(reply.value) + } + + socket.on('data', onData) + socket.once('error', onError) + socket.once('close', onClose) + }) +} + +function parseMethodReply(buffer: Buffer): ParsedReply | undefined { + if (buffer.length < 2) return undefined + if (buffer[0] !== 0x05) fail('invalid SOCKS version') + return { consumed: 2, value: buffer[1] === 0x02 } +} + +function parseAuthReply(buffer: Buffer): ParsedReply | undefined { + if (buffer.length < 2) return undefined + if (buffer[0] !== 0x01) fail('invalid SOCKS auth version') + return { consumed: 2, value: buffer[1] === 0x00 } +} + +function parseConnectReply(buffer: Buffer): ParsedReply | undefined { + if (buffer.length < 5) return undefined + if (buffer[0] !== 0x05) fail('invalid SOCKS version') + + let addressLength: number + switch (buffer[3]) { + case 0x01: + addressLength = 4 + break + case 0x03: + if (buffer.length < 5) return undefined + addressLength = 1 + buffer[4]! + break + case 0x04: + addressLength = 16 + break + default: + fail('invalid SOCKS address type') + } + + const consumed = 4 + addressLength + 2 + if (buffer.length < consumed) return undefined + return { consumed, value: buffer[1] === 0x00 } +} + +async function main(): Promise { + const socket = connect(proxyPort, '127.0.0.1') + await new Promise((resolve, reject) => { + socket.once('connect', resolve) + socket.once('error', reject) + }) + + socket.write(Buffer.from([0x05, 0x01, 0x02])) + if (!(await readReply(socket, parseMethodReply))) { + fail('SOCKS proxy does not support username/password authentication') + } + + const user = Buffer.from(proxyUsername) + const pass = Buffer.from(proxyPassword) + socket.write( + Buffer.concat([ + Buffer.from([0x01, user.length]), + user, + Buffer.from([pass.length]), + pass, + ]), + ) + if (!(await readReply(socket, parseAuthReply))) { + fail('SOCKS proxy authentication failed') + } + + const hostBytes = Buffer.from(host) + socket.write( + Buffer.concat([ + Buffer.from([0x05, 0x01, 0x00, 0x03, hostBytes.length]), + hostBytes, + Buffer.from([(destinationPort >> 8) & 0xff, destinationPort & 0xff]), + ]), + ) + if (!(await readReply(socket, parseConnectReply))) { + fail('SOCKS proxy CONNECT failed') + } + + process.stdin.pipe(socket) + socket.pipe(process.stdout) +} + +main().catch(error => { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ) + process.exitCode = 1 +}) diff --git a/test/sandbox/proxy-env-vars.test.ts b/test/sandbox/proxy-env-vars.test.ts index 5c99c65a..e989da7b 100644 --- a/test/sandbox/proxy-env-vars.test.ts +++ b/test/sandbox/proxy-env-vars.test.ts @@ -10,8 +10,25 @@ import { SandboxManager } from '../../src/sandbox/sandbox-manager.js' import type { SandboxRuntimeConfig } from '../../src/sandbox/sandbox-config.js' import { spawnAsync } from '../helpers/spawn.js' import { isLinux } from '../helpers/platform.js' +import { isMacOS } from '../helpers/platform.js' describe('generateProxyEnvVars', () => { + it.if(isMacOS)('uses the authenticated SOCKS client for Git SSH', () => { + const env = generateProxyEnvVars(3128, 1080, undefined, 'tok-123') + const command = env.find(value => value.startsWith('GIT_SSH_COMMAND=')) + + expect(command).toMatch(/socks5-proxy-command\.(ts|js)/) + expect(command).toContain('"srt" "tok-123" "%h" "%p"') + expect(command).not.toContain('nc -X 5') + }) + + it.if(isMacOS)('keeps BSD nc for unauthenticated Git SSH', () => { + const env = generateProxyEnvVars(3128, 1080) + expect(env).toContain( + "GIT_SSH_COMMAND=ssh -o ControlMaster=no -o ControlPath=none -o ProxyCommand='nc -X 5 -x localhost:1080 %h %p'", + ) + }) + it('sets CLOUDSDK_PROXY_TYPE to http (gcloud rejects "https")', () => { // gcloud's proxy/type only accepts http, http_no_tunnel, socks4, socks5. // Our local proxy is an HTTP CONNECT proxy regardless of the traffic it diff --git a/test/sandbox/socks-authenticated-client.test.ts b/test/sandbox/socks-authenticated-client.test.ts new file mode 100644 index 00000000..0123139a --- /dev/null +++ b/test/sandbox/socks-authenticated-client.test.ts @@ -0,0 +1,97 @@ +import { spawn } from 'node:child_process' +import { createServer, type Server } from 'node:net' +import { once } from 'node:events' +import { afterEach, describe, expect, it } from 'bun:test' +import { createSocksProxyServer } from '../../src/sandbox/socks-proxy.js' +import { isMacOS } from '../helpers/platform.js' + +describe.if(isMacOS)('authenticated SOCKS5 ProxyCommand client', () => { + let proxyTcp: Server | undefined + let targetTcp: Server | undefined + let wrapper: ReturnType | undefined + + afterEach(async () => { + await wrapper?.close() + proxyTcp?.close() + targetTcp?.close() + wrapper = undefined + proxyTcp = undefined + targetTcp = undefined + }) + + async function listen(server: Server): Promise { + server.listen(0, '127.0.0.1') + await once(server, 'listening') + return (server.address() as { port: number }).port + } + + async function start(): Promise<{ proxyPort: number; targetPort: number }> { + targetTcp = createServer(socket => { + socket.on('data', data => socket.write(data)) + }) + const targetPort = await listen(targetTcp) + + wrapper = createSocksProxyServer({ + proxyAuthToken: 'tok-123', + filter: () => true, + }) + proxyTcp = createServer(socket => wrapper!.handleConnection(socket)) + const proxyPort = await listen(proxyTcp) + return { proxyPort, targetPort } + } + + it('authenticates and pipes bytes to the requested destination', async () => { + const { proxyPort, targetPort } = await start() + const helper = new URL( + '../../src/sandbox/socks5-proxy-command.ts', + import.meta.url, + ) + const child = spawn(process.execPath, [ + helper.pathname, + String(proxyPort), + 'srt.command', + 'tok-123', + '127.0.0.1', + String(targetPort), + ]) + const output: Buffer[] = [] + child.stdout.on('data', chunk => output.push(chunk)) + child.stdin.write('round-trip') + + await new Promise((resolve, reject) => { + child.stdout.on('data', () => { + child.kill() + resolve() + }) + child.once('error', reject) + }) + + expect(Buffer.concat(output).toString()).toBe('round-trip') + }) + + it('rejects an invalid token without contacting the destination', async () => { + const { proxyPort, targetPort } = await start() + let contacted = false + targetTcp!.close() + targetTcp = createServer(() => { + contacted = true + }) + const replacementTargetPort = await listen(targetTcp) + const helper = new URL( + '../../src/sandbox/socks5-proxy-command.ts', + import.meta.url, + ) + const child = spawn(process.execPath, [ + helper.pathname, + String(proxyPort), + 'srt.command', + 'wrong-token', + '127.0.0.1', + String(replacementTargetPort || targetPort), + ]) + const exitCode = await once(child, 'close') + + expect(exitCode[0]).not.toBe(0) + expect(contacted).toBe(false) + }) +})