Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions src/sandbox/sandbox-utils.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 `?`
Expand Down Expand Up @@ -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.
Expand Down
157 changes: 157 additions & 0 deletions src/sandbox/socks5-proxy-command.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<void> {
const socket = connect(proxyPort, '127.0.0.1')
await new Promise<void>((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
})
17 changes: 17 additions & 0 deletions test/sandbox/proxy-env-vars.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 97 additions & 0 deletions test/sandbox/socks-authenticated-client.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createSocksProxyServer> | undefined

afterEach(async () => {
await wrapper?.close()
proxyTcp?.close()
targetTcp?.close()
wrapper = undefined
proxyTcp = undefined
targetTcp = undefined
})

async function listen(server: Server): Promise<number> {
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<void>((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)
})
})