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
2 changes: 1 addition & 1 deletion src/sandbox/http-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ export function createHttpProxyServer(options: HttpProxyServerOptions): Server {
(options.shouldTerminateTLS?.(hostname, port) ?? true)
) {
// We can only terminate TLS. CONNECT also carries non-TLS streams —
// notably SSH on Linux, where the sandbox's own GIT_SSH_COMMAND
// notably SSH, where the sandbox's own GIT_SSH_COMMAND
// routes `ssh` through this proxy via `socat - PROXY:`. Send 200 so
// the client transmits its first bytes, sniff for a ClientHello, and
// only terminate if it is one. Non-TLS falls through to the opaque
Expand Down
2 changes: 1 addition & 1 deletion src/sandbox/linux-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1170,7 +1170,7 @@ function probeUid0UserNamespace(bwrap: string): string | null {
*
* 3. Configure environment:
* - HTTP_PROXY=http://localhost:3128 for HTTP/HTTPS tools
* - GIT_SSH_COMMAND with socat for SSH through SOCKS5
* - GIT_SSH_COMMAND with socat for SSH through HTTP CONNECT
*
* LIMITATION: Unlike macOS sandbox which can enforce domain-based allowlists at the kernel level,
* Linux's --unshare-net provides only all-or-nothing network isolation. Domain filtering happens
Expand Down
28 changes: 22 additions & 6 deletions src/sandbox/sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from 'path'
import * as fs from 'fs'
import { getPlatform } from '../utils/platform.js'
import { logForDebugging } from '../utils/debug.js'
import { whichSync } from '../utils/which.js'

/**
* Dangerous files that should be protected from writes.
Expand Down Expand Up @@ -796,12 +797,27 @@ 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).
envVars.push(
`GIT_SSH_COMMAND=ssh ${sshMuxOverride} -o ProxyCommand='nc -X 5 -x localhost:${socksProxyPort} %h %p'`,
)
if (httpProxyPort && whichSync('socat')) {
// macOS with socat available: same HTTP CONNECT spelling as Linux.
// This is the only stock ProxyCommand tool that can authenticate to
// the proxy — BSD nc (below) cannot, so socat is what makes
// git-over-ssh work when proxyAuthToken is set. socat is not a
// required macOS dependency; detected per call, nc fallback below.
const socatAuth = proxyAuthToken
? `,proxyauth=${userRaw}:${proxyAuthToken}`
: ''
envVars.push(
`GIT_SSH_COMMAND=ssh ${sshMuxOverride} -o ProxyCommand='socat - PROXY:localhost:%h:%p,proxyport=${httpProxyPort}${socatAuth}'`,
)
} else {
// Fallback: 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 — install socat, or use git-over-https
// (HTTP_PROXY carries the credential).
envVars.push(
`GIT_SSH_COMMAND=ssh ${sshMuxOverride} -o ProxyCommand='nc -X 5 -x localhost:${socksProxyPort} %h %p'`,
)
}
} else if (platform === 'linux' && httpProxyPort) {
// Linux: use socat HTTP CONNECT via the HTTP proxy bridge.
// socat is already a required Linux sandbox dependency, and PROXY: is
Expand Down
2 changes: 1 addition & 1 deletion test/sandbox/connect-non-tls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { looksLikeClientHello } from '../../src/sandbox/tls-terminate-proxy.js'

/**
* Regression: with tlsTerminate on, every CONNECT was handed to the
* TLS terminator regardless of port/protocol. The Linux sandbox routes
* TLS terminator regardless of port/protocol. The sandbox routes
* `git push` over SSH through this proxy via
* GIT_SSH_COMMAND=ssh -o ProxyCommand='socat - PROXY:localhost:%h:%p,...'
* which is `CONNECT github.com:22` — not TLS. The terminator replied with a
Expand Down
70 changes: 69 additions & 1 deletion test/sandbox/proxy-env-vars.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { describe, it, expect } from 'bun:test'
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'
import { createServer } from 'node:http'
import type { Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import {
generateProxyEnvVars,
CA_TRUST_VARS,
} from '../../src/sandbox/sandbox-utils.js'
import * as which from '../../src/utils/which.js'
import * as platform from '../../src/utils/platform.js'
import { SandboxManager } from '../../src/sandbox/sandbox-manager.js'
import type { SandboxRuntimeConfig } from '../../src/sandbox/sandbox-config.js'
import { spawnAsync } from '../helpers/spawn.js'
Expand Down Expand Up @@ -98,6 +100,72 @@ describe('generateProxyEnvVars', () => {
})
})

describe('GIT_SSH_COMMAND on macOS', () => {
// The proxy requires per-session auth when SRT owns it, and BSD nc has
// no SOCKS5 auth — so with socat available, macOS must emit the same
// authenticated HTTP CONNECT spelling as Linux, or git-over-ssh dies at
// the SOCKS handshake. The nc spelling is only a fallback for hosts
// without socat.
const MUX = '-o ControlMaster=no -o ControlPath=none'
let whichSpy: ReturnType<typeof spyOn>
let platformSpy: ReturnType<typeof spyOn>

beforeEach(() => {
whichSpy = spyOn(which, 'whichSync')
platformSpy = spyOn(platform, 'getPlatform')
platformSpy.mockReturnValue('macos')
})

afterEach(() => {
whichSpy.mockRestore()
platformSpy.mockRestore()
})

const socatPresent = (bin: string) =>
bin === 'socat' ? '/opt/homebrew/bin/socat' : `/usr/bin/${bin}`
const socatAbsent = (bin: string) =>
bin === 'socat' ? null : `/usr/bin/${bin}`

it('uses socat with proxyauth when socat is on PATH and auth is set', () => {
whichSpy.mockImplementation(socatPresent)
const env = generateProxyEnvVars(3128, 1080, undefined, 'tok')
expect(env).toContain(
`GIT_SSH_COMMAND=ssh ${MUX} -o ProxyCommand='socat - PROXY:localhost:%h:%p,proxyport=3128,proxyauth=srt:tok'`,
)
})

it('carries per-command attribution in the proxyauth username', () => {
whichSpy.mockImplementation(socatPresent)
const env = generateProxyEnvVars(
3128,
1080,
undefined,
'tok',
undefined,
'dGVzdA',
)
expect(env).toContain(
`GIT_SSH_COMMAND=ssh ${MUX} -o ProxyCommand='socat - PROXY:localhost:%h:%p,proxyport=3128,proxyauth=srt.dGVzdA:tok'`,
)
})

it('omits proxyauth when no auth token is configured', () => {
whichSpy.mockImplementation(socatPresent)
const env = generateProxyEnvVars(3128, 1080)
expect(env).toContain(
`GIT_SSH_COMMAND=ssh ${MUX} -o ProxyCommand='socat - PROXY:localhost:%h:%p,proxyport=3128'`,
)
})

it('falls back to the nc spelling when socat is not installed', () => {
whichSpy.mockImplementation(socatAbsent)
const env = generateProxyEnvVars(3128, 1080, undefined, 'tok')
expect(env).toContain(
`GIT_SSH_COMMAND=ssh ${MUX} -o ProxyCommand='nc -X 5 -x localhost:1080 %h %p'`,
)
})
})

describe('NO_PROXY', () => {
it('does not exclude .local hostnames from the proxy', () => {
// Under network restriction the child has no usable resolver, so a
Expand Down