chore: cleanup old sandbox (#7700)

This commit is contained in:
Michael Neale
2026-03-06 12:13:37 -05:00
committed by Jack Amadeo
parent ba1e8b8543
commit 3a15b01dcc
9 changed files with 5 additions and 1306 deletions
@@ -1,172 +0,0 @@
//! Integration tests for the Lapstone HTTP tunnel
//!
//! These tests verify the full tunnel flow:
//! 1. Start a local HTTP server
//! 2. Start the tunnel (connects to real Cloudflare worker via WebSocket)
//! 3. Make requests to the public HTTPS URL
//! 4. Verify they proxy through to the local server
use super::lapstone;
use axum::{
extract::Request,
response::Json,
routing::{get, post},
Router,
};
use serde_json::{json, Value};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock};
const TEST_TUNNEL_SECRET: &str = "test-tunnel-secret-12345";
const TEST_SERVER_SECRET: &str = "test-server-secret-67890";
async fn find_available_port() -> u16 {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("Failed to bind to port 0");
let addr = listener.local_addr().expect("Failed to get local address");
addr.port()
}
async fn health_handler() -> Json<Value> {
Json(json!({
"status": "ok",
"message": "Test server is running"
}))
}
async fn echo_handler(req: Request) -> Json<Value> {
let headers: Vec<(String, String)> = req
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let body = axum::body::to_bytes(req.into_body(), usize::MAX)
.await
.unwrap_or_default();
let body_str = String::from_utf8_lossy(&body).to_string();
Json(json!({
"headers": headers,
"body": body_str
}))
}
fn create_test_server() -> Router {
Router::new()
.route("/health", get(health_handler))
.route("/echo", post(echo_handler))
}
async fn start_test_http_server(port: u16) -> tokio::task::JoinHandle<()> {
let app = create_test_server();
let addr = SocketAddr::from(([127, 0, 0, 1], port));
tokio::spawn(async move {
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
})
}
#[tokio::test]
async fn test_tunnel_end_to_end() {
let port = find_available_port().await;
let server_handle = start_test_http_server(port).await;
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
let handle = Arc::new(RwLock::new(None));
let (restart_tx, _restart_rx) = mpsc::channel(1);
let tunnel_secret = TEST_TUNNEL_SECRET.to_string();
let server_secret = TEST_SERVER_SECRET.to_string();
let agent_id = super::generate_agent_id();
let tunnel_info = lapstone::start(
port,
tunnel_secret.clone(),
server_secret.clone(),
agent_id.clone(),
"http",
handle.clone(),
restart_tx,
)
.await
.expect("Failed to start tunnel");
let public_url = &tunnel_info.url;
println!("Tunnel public URL: {}", public_url);
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let client = reqwest::Client::new();
let response = client
.get(format!("{}/health", public_url))
.header("X-Secret-Key", &tunnel_secret)
.send()
.await
.expect("Failed to make request to public URL");
assert!(
response.status().is_success(),
"Response status: {}",
response.status()
);
let body: Value = response.json().await.expect("Failed to parse JSON");
assert_eq!(body["status"], "ok");
assert_eq!(body["message"], "Test server is running");
lapstone::stop(handle).await;
server_handle.abort();
}
#[tokio::test]
async fn test_tunnel_post_request() {
let port = find_available_port().await;
let server_handle = start_test_http_server(port).await;
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
let handle = Arc::new(RwLock::new(None));
let (restart_tx, _restart_rx) = mpsc::channel(1);
let tunnel_secret = TEST_TUNNEL_SECRET.to_string();
let server_secret = TEST_SERVER_SECRET.to_string();
let agent_id = super::generate_agent_id();
let tunnel_info = lapstone::start(
port,
tunnel_secret.clone(),
server_secret.clone(),
agent_id.clone(),
"http",
handle.clone(),
restart_tx,
)
.await
.expect("Failed to start tunnel");
let public_url = &tunnel_info.url;
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let client = reqwest::Client::new();
let test_body = json!({"test": "data", "number": 42});
let response = client
.post(format!("{}/echo", public_url))
.header("X-Secret-Key", &tunnel_secret)
.header("Content-Type", "application/json")
.json(&test_body)
.send()
.await
.expect("Failed to make POST request");
assert!(response.status().is_success());
let body: Value = response.json().await.expect("Failed to parse JSON");
assert!(body["body"].as_str().unwrap().contains("test"));
assert!(body["body"].as_str().unwrap().contains("data"));
assert!(body["body"].as_str().unwrap().contains("42"));
lapstone::stop(handle).await;
server_handle.abort();
}
-3
View File
@@ -1,8 +1,5 @@
pub mod lapstone;
#[cfg(test)]
mod lapstone_test;
use crate::configuration::Settings;
use fs2::FileExt as _;
use goose::config::{paths::Paths, Config};
+1 -1
View File
@@ -4,7 +4,7 @@ const { resolve } = require('path');
let cfg = {
asar: true,
extraResource: ['src/bin', 'src/images', 'src/sandbox'],
extraResource: ['src/bin', 'src/images'],
icon: 'src/images/icon',
// Windows specific configuration
win32: {
+4 -32
View File
@@ -6,13 +6,7 @@ import { createServer } from 'net';
import { Buffer } from 'node:buffer';
import { status } from './api';
import { Client, createClient, createConfig } from './api/client';
import {
buildSandboxSpawn,
ensureProxy,
stopProxy,
isSandboxEnabled,
isSandboxAvailable,
} from './sandbox';
export interface Logger {
info: (...args: unknown[]) => void;
@@ -232,17 +226,10 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
};
}
if (isSandboxEnabled() && !isSandboxAvailable()) {
throw new Error('GOOSE_SANDBOX=true but sandbox-exec is not available (macOS only)');
}
const useSandbox = isSandboxEnabled();
const goosedPath = findGoosedBinaryPath({ isPackaged, resourcesPath });
const port = await findAvailablePort();
logger.info(
`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}${useSandbox ? ' [SANDBOXED]' : ''}`
);
logger.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}`);
const baseUrl = `https://127.0.0.1:${port}`;
@@ -257,19 +244,8 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
}
}
// If sandbox mode, start proxy and wrap with sandbox-exec
let spawnCommand = goosedPath;
let spawnArgs = ['agent'];
if (useSandbox) {
const proxy = await ensureProxy();
const sandboxSpawn = buildSandboxSpawn(goosedPath, ['agent'], proxy.port);
spawnCommand = sandboxSpawn.command;
spawnArgs = sandboxSpawn.args;
// Merge proxy env vars into the process env
Object.assign(spawnEnv, sandboxSpawn.env);
logger.info(`[sandbox] Spawning via: ${spawnCommand} ${spawnArgs.join(' ')}`);
}
const spawnCommand = goosedPath;
const spawnArgs = ['agent'];
const isWindows = process.platform === 'win32';
const spawnOptions = {
@@ -368,10 +344,6 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
logger.error('Error while terminating goosed process:', error);
}
if (useSandbox) {
stopProxy().catch((err) => logger.error('Error stopping sandbox proxy:', err));
}
setTimeout(() => {
if (goosedProcess && !goosedProcess.killed && process.platform !== 'win32') {
goosedProcess.kill('SIGKILL');
-10
View File
@@ -1,10 +0,0 @@
# Blocked domains — edit this file while goosed is running.
# Changes take effect immediately (re-read on every connection).
# One domain per line. Subdomains are blocked automatically.
# Lines starting with # are comments.
#
# Examples:
# evil.com — blocks evil.com and *.evil.com
# pastebin.com — blocks pastebin.com and *.pastebin.com
# transfer.sh
# webhook.site
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/perl
use strict;
use warnings;
use IO::Socket::INET;
use IO::Select;
my ($host, $port) = @ARGV;
die "Usage: connect-proxy.pl <host> <port>\n" unless $host && $port;
my $proxy_port = $ENV{SANDBOX_PROXY_PORT} || die "SANDBOX_PROXY_PORT not set\n";
my $sock = IO::Socket::INET->new(
PeerAddr => '127.0.0.1',
PeerPort => $proxy_port,
Proto => 'tcp',
) or die "Cannot connect to proxy: $!\n";
print $sock "CONNECT $host:$port HTTP/1.1\r\nHost: $host:$port\r\n\r\n";
my $status = <$sock>;
die "Proxy error: $status" unless $status && $status =~ /\b200\b/;
while (my $hdr = <$sock>) {
last if $hdr =~ /^\r?\n$/;
}
$| = 1;
binmode STDIN;
binmode STDOUT;
binmode $sock;
my $sel = IO::Select->new($sock, \*STDIN);
while (my @ready = $sel->can_read()) {
for my $fh (@ready) {
my $buf;
my $n = sysread($fh, $buf, 8192);
exit 0 unless $n;
if ($fh == $sock) {
syswrite(STDOUT, $buf) or exit 0;
} else {
syswrite($sock, $buf) or exit 0;
}
}
}
-238
View File
@@ -1,238 +0,0 @@
/**
* macOS Seatbelt sandbox for goosed.
*
* GOOSE_SANDBOX=true — enable sandbox
* LAUNCHDARKLY_CLIENT_ID=sdk-xxx — optional LD egress control
*
* Seatbelt profile options (all default to enabled):
* GOOSE_SANDBOX_PROTECT_FILES=false — disable SSH/shell config protection
* GOOSE_SANDBOX_BLOCK_RAW_SOCKETS=false — disable raw socket blocking
* GOOSE_SANDBOX_BLOCK_TUNNELING=false — disable tunneling tool blocking
*
* Proxy options:
* GOOSE_SANDBOX_ALLOW_IP=true — allow raw IP address connections
* GOOSE_SANDBOX_BLOCK_LOOPBACK=true — block loopback via proxy (default: off)
* GOOSE_SANDBOX_ALLOW_SSH=false — block SSH ports (22/2222/7999) via proxy
* GOOSE_SANDBOX_GIT_HOSTS=host1,host2 — custom git host allowlist for SSH
* GOOSE_SANDBOX_SSH_ALL_HOSTS=true — allow SSH to all hosts (default: git hosts only)
*
* SSH git operations (git clone git@...) are routed through the proxy via
* a bundled connect-proxy.pl script used as SSH ProxyCommand. This avoids
* needing nc (which is blocked by the seatbelt profile).
* GOOSE_SANDBOX_LD_FAILOVER=allow|deny|blocklist — LD failover mode
*/
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { startProxy, ProxyInstance } from './proxy';
export { startProxy } from './proxy';
export type { ProxyInstance } from './proxy';
const homeDir = os.homedir();
const sandboxDir = path.join(homeDir, '.config', 'goose', 'sandbox');
// ---------------------------------------------------------------------------
// Sandbox profile builder
// ---------------------------------------------------------------------------
export interface SandboxProfileOptions {
homeDir: string;
protectSensitiveFiles: boolean;
blockRawSockets: boolean;
blockTunnelingTools: boolean;
}
export function buildSandboxProfile(opts: SandboxProfileOptions): string {
const h = opts.homeDir;
const lines: string[] = [
'(version 1)',
'(allow default)',
'',
`;; Protect sandbox config from the sandboxed process`,
`(deny file-write* (subpath "${h}/.config/goose/sandbox"))`,
];
if (opts.protectSensitiveFiles) {
lines.push(
'',
`(deny file-write* (subpath "${h}/.ssh"))`,
`(deny file-write* (literal "${h}/.bashrc"))`,
`(deny file-write* (literal "${h}/.zshrc"))`,
`(deny file-write* (literal "${h}/.bash_profile"))`,
`(deny file-write* (literal "${h}/.zprofile"))`
);
}
lines.push(
'',
'(deny network*)',
'(allow network-outbound (literal "/private/var/run/mDNSResponder"))',
'(allow network-outbound (remote unix-socket))',
'(allow network-outbound (remote ip "localhost:*"))',
'(allow network-inbound (local ip "localhost:*"))'
);
if (opts.blockRawSockets) {
lines.push(
'',
'(deny system-socket (require-all (socket-domain AF_INET) (socket-type SOCK_RAW)))',
'(deny system-socket (require-all (socket-domain AF_INET6) (socket-type SOCK_RAW)))'
);
}
if (opts.blockTunnelingTools) {
lines.push(
'',
'(deny process-exec',
' (literal "/usr/bin/nc")',
' (literal "/usr/bin/ncat")',
' (literal "/usr/bin/netcat")',
' (literal "/usr/bin/socat")',
' (literal "/usr/bin/telnet")',
')'
);
}
lines.push('', '(deny system-kext-load)', '');
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function isSandboxEnabled(): boolean {
return process.env.GOOSE_SANDBOX === 'true' || process.env.GOOSE_SANDBOX === '1';
}
export function isSandboxAvailable(): boolean {
return process.platform === 'darwin' && fs.existsSync('/usr/bin/sandbox-exec');
}
function bundledPath(filename: string): string {
// In packaged apps, process.resourcesPath points to the app resources directory.
// In development, fall back to the source tree.
const packagedPath = process.resourcesPath
? path.join(process.resourcesPath, 'sandbox', filename)
: '';
if (packagedPath && fs.existsSync(packagedPath)) {
return packagedPath;
}
return path.join(process.cwd(), 'src', 'sandbox', filename);
}
function materialise(filename: string): string {
const runtimePath = path.join(sandboxDir, filename);
if (!fs.existsSync(runtimePath)) {
fs.mkdirSync(sandboxDir, { recursive: true });
const content = fs.readFileSync(bundledPath(filename), 'utf-8');
fs.writeFileSync(runtimePath, content);
console.log(`[sandbox] Materialised ${filename}`);
}
return runtimePath;
}
function writeSandboxProfile(content: string): string {
const runtimePath = path.join(sandboxDir, 'sandbox.sb');
fs.mkdirSync(sandboxDir, { recursive: true });
fs.writeFileSync(runtimePath, content);
return runtimePath;
}
function writeConnectProxy(): string {
const runtimePath = path.join(sandboxDir, 'connect-proxy.pl');
fs.mkdirSync(sandboxDir, { recursive: true });
const content = fs.readFileSync(bundledPath('connect-proxy.pl'), 'utf-8');
fs.writeFileSync(runtimePath, content, { mode: 0o755 });
return runtimePath;
}
// ---------------------------------------------------------------------------
// Spawn
// ---------------------------------------------------------------------------
export function buildSandboxSpawn(
goosedPath: string,
goosedArgs: string[],
proxyPort: number
): { command: string; args: string[]; env: Record<string, string> } {
const profileOptions: SandboxProfileOptions = {
homeDir,
protectSensitiveFiles: process.env.GOOSE_SANDBOX_PROTECT_FILES !== 'false',
blockRawSockets: process.env.GOOSE_SANDBOX_BLOCK_RAW_SOCKETS !== 'false',
blockTunnelingTools: process.env.GOOSE_SANDBOX_BLOCK_TUNNELING !== 'false',
};
const profileContent = buildSandboxProfile(profileOptions);
const sandboxProfile = writeSandboxProfile(profileContent);
const proxyUrl = `http://127.0.0.1:${proxyPort}`;
const connectProxy = writeConnectProxy();
console.log(`[sandbox] Profile: ${sandboxProfile}`);
console.log(`[sandbox] Proxy port: ${proxyPort}`);
console.log(
`[sandbox] Config: protectSensitiveFiles=${profileOptions.protectSensitiveFiles}, blockRawSockets=${profileOptions.blockRawSockets}, blockTunnelingTools=${profileOptions.blockTunnelingTools}`
);
return {
command: '/usr/bin/sandbox-exec',
args: ['-f', sandboxProfile, goosedPath, ...goosedArgs],
env: {
http_proxy: proxyUrl,
https_proxy: proxyUrl,
HTTP_PROXY: proxyUrl,
HTTPS_PROXY: proxyUrl,
no_proxy: 'localhost,127.0.0.1,::1',
NO_PROXY: 'localhost,127.0.0.1,::1',
GIT_SSH_COMMAND: `ssh -o ProxyCommand='/usr/bin/perl "${connectProxy}" %h %p'`,
SANDBOX_PROXY_PORT: String(proxyPort),
},
};
}
// ---------------------------------------------------------------------------
// Proxy lifecycle
// ---------------------------------------------------------------------------
let activeProxy: ProxyInstance | null = null;
export async function ensureProxy(): Promise<ProxyInstance> {
if (activeProxy) return activeProxy;
const ldClientId = process.env.LAUNCHDARKLY_CLIENT_ID;
const blockedPath = materialise('blocked.txt');
activeProxy = await startProxy({
blockedPath,
launchDarkly: ldClientId
? {
clientId: ldClientId,
username: os.userInfo().username,
failoverMode:
(process.env.GOOSE_SANDBOX_LD_FAILOVER as 'allow' | 'deny' | 'blocklist') || undefined,
}
: undefined,
allowIPAddresses: process.env.GOOSE_SANDBOX_ALLOW_IP === 'true',
blockLoopback: process.env.GOOSE_SANDBOX_BLOCK_LOOPBACK === 'true',
allowSSH: process.env.GOOSE_SANDBOX_ALLOW_SSH !== 'false',
gitHosts:
process.env.GOOSE_SANDBOX_GIT_HOSTS?.split(',')
.map((h) => h.trim())
.filter(Boolean) || undefined,
allowSSHToAllHosts: process.env.GOOSE_SANDBOX_SSH_ALL_HOSTS === 'true',
});
console.log(`[sandbox] Proxy started on port ${activeProxy.port}`);
return activeProxy;
}
export async function stopProxy(): Promise<void> {
if (activeProxy) {
await activeProxy.close();
console.log('[sandbox] Proxy stopped');
activeProxy = null;
}
}
-275
View File
@@ -1,275 +0,0 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
normalizeDomain,
isIPAddress,
isLoopback,
matchesBlocked,
checkBlocked,
loadBlocked,
parseConnectTarget,
type ProxyOptions,
} from './proxy';
describe('parseConnectTarget', () => {
it('parses host:port', () => {
expect(parseConnectTarget('example.com:443')).toEqual({ host: 'example.com', port: 443 });
});
it('parses host:port with non-standard port', () => {
expect(parseConnectTarget('api.internal:8443')).toEqual({ host: 'api.internal', port: 8443 });
});
it('parses bracketed IPv6 with port', () => {
expect(parseConnectTarget('[2001:db8::1]:443')).toEqual({ host: '2001:db8::1', port: 443 });
expect(parseConnectTarget('[::1]:8080')).toEqual({ host: '::1', port: 8080 });
});
it('rejects invalid targets', () => {
expect(parseConnectTarget(':443')).toEqual({ host: '', port: 0 });
expect(parseConnectTarget('')).toEqual({ host: '', port: 0 });
expect(parseConnectTarget('example.com')).toEqual({ host: '', port: 0 });
expect(parseConnectTarget('example.com:0')).toEqual({ host: '', port: 0 });
expect(parseConnectTarget('example.com:99999')).toEqual({ host: '', port: 0 });
expect(parseConnectTarget('example.com:abc')).toEqual({ host: '', port: 0 });
});
});
describe('normalizeDomain', () => {
it('lowercases and trims', () => {
expect(normalizeDomain('GitHub.COM')).toBe('github.com');
expect(normalizeDomain(' example.com ')).toBe('example.com');
});
it('strips trailing dot', () => {
expect(normalizeDomain('example.com.')).toBe('example.com');
});
it('strips IPv6 brackets', () => {
expect(normalizeDomain('[::1]')).toBe('::1');
});
it('handles punycode via URL constructor', () => {
expect(normalizeDomain('MÜNCHEN.de')).toBe(new URL('http://münchen.de').hostname);
});
it('handles plain domain', () => {
expect(normalizeDomain('api.example.com')).toBe('api.example.com');
});
});
describe('isIPAddress', () => {
it('detects IPv4', () => {
expect(isIPAddress('192.168.1.1')).toBe(true);
expect(isIPAddress('10.0.0.1')).toBe(true);
expect(isIPAddress('127.0.0.1')).toBe(true);
});
it('detects IPv6', () => {
expect(isIPAddress('::1')).toBe(true);
expect(isIPAddress('2001:db8::1')).toBe(true);
});
it('rejects domains', () => {
expect(isIPAddress('example.com')).toBe(false);
expect(isIPAddress('localhost')).toBe(false);
});
});
describe('isLoopback', () => {
it('matches loopback addresses', () => {
expect(isLoopback('localhost')).toBe(true);
expect(isLoopback('LOCALHOST')).toBe(true);
expect(isLoopback('127.0.0.1')).toBe(true);
expect(isLoopback('127.255.255.255')).toBe(true);
expect(isLoopback('::1')).toBe(true);
expect(isLoopback('[::1]')).toBe(true);
});
it('rejects non-loopback', () => {
expect(isLoopback('192.168.1.1')).toBe(false);
expect(isLoopback('example.com')).toBe(false);
});
});
describe('matchesBlocked', () => {
const blocked = new Set(['evil.com', 'pastebin.com', 'bad.example.org']);
it('blocks exact domain and subdomains', () => {
expect(matchesBlocked('evil.com', blocked)).toBe(true);
expect(matchesBlocked('www.evil.com', blocked)).toBe(true);
expect(matchesBlocked('deep.sub.evil.com', blocked)).toBe(true);
});
it('allows non-blocked domains', () => {
expect(matchesBlocked('github.com', blocked)).toBe(false);
expect(matchesBlocked('example.com', blocked)).toBe(false);
});
it('does not block parent of blocked domain', () => {
expect(matchesBlocked('example.org', blocked)).toBe(false);
expect(matchesBlocked('com', blocked)).toBe(false);
});
it('is case-insensitive and handles trailing dot', () => {
expect(matchesBlocked('EVIL.COM', blocked)).toBe(true);
expect(matchesBlocked('evil.com.', blocked)).toBe(true);
});
it('handles empty blocklist', () => {
expect(matchesBlocked('anything.com', new Set())).toBe(false);
});
});
describe('loadBlocked', () => {
it('returns empty set for undefined or missing path', () => {
expect(loadBlocked(undefined).size).toBe(0);
expect(loadBlocked('/nonexistent/path/blocked.txt').size).toBe(0);
});
it('loads domains from file, skipping comments and blanks', () => {
const tmpFile = path.join(os.tmpdir(), `blocked-test-${Date.now()}.txt`);
fs.writeFileSync(
tmpFile,
`# comment
evil.com
pastebin.com
# another comment
transfer.sh
`
);
try {
const result = loadBlocked(tmpFile);
expect(result.size).toBe(3);
expect(result.has('evil.com')).toBe(true);
expect(result.has('pastebin.com')).toBe(true);
expect(result.has('transfer.sh')).toBe(true);
} finally {
fs.unlinkSync(tmpFile);
}
});
});
describe('checkBlocked', () => {
const blocked = new Set(['evil.com', 'pastebin.com']);
const noLD = undefined;
const noLDCache = undefined;
const defaultOptions: ProxyOptions = {};
it('allows normal HTTPS traffic', async () => {
const result = await checkBlocked('github.com', 443, blocked, noLD, noLDCache, defaultOptions);
expect(result.blocked).toBe(false);
});
it('blocks domains and subdomains on the blocklist', async () => {
const exact = await checkBlocked('evil.com', 443, blocked, noLD, noLDCache, defaultOptions);
expect(exact).toEqual({ blocked: true, reason: 'blocklist' });
const sub = await checkBlocked('api.evil.com', 443, blocked, noLD, noLDCache, defaultOptions);
expect(sub).toEqual({ blocked: true, reason: 'blocklist' });
});
it('blocks raw IP addresses by default, allows when opted in', async () => {
const blocked_ = await checkBlocked(
'93.184.216.34',
443,
blocked,
noLD,
noLDCache,
defaultOptions
);
expect(blocked_).toEqual({ blocked: true, reason: 'ip-address' });
const allowed = await checkBlocked('93.184.216.34', 443, blocked, noLD, noLDCache, {
allowIPAddresses: true,
});
expect(allowed.blocked).toBe(false);
});
it('does not block loopback by default, blocks when opted in', async () => {
const allowed = await checkBlocked('localhost', 8080, blocked, noLD, noLDCache, defaultOptions);
expect(allowed.blocked).toBe(false);
const blocked_ = await checkBlocked('localhost', 8080, blocked, noLD, noLDCache, {
blockLoopback: true,
});
expect(blocked_).toEqual({ blocked: true, reason: 'loopback' });
const blocked127 = await checkBlocked('127.0.0.1', 8080, blocked, noLD, noLDCache, {
blockLoopback: true,
});
expect(blocked127).toEqual({ blocked: true, reason: 'loopback' });
});
it('allows SSH to default git hosts', async () => {
for (const host of ['github.com', 'gitlab.com', 'bitbucket.org', 'ssh.dev.azure.com']) {
const result = await checkBlocked(host, 22, blocked, noLD, noLDCache, defaultOptions);
expect(result.blocked).toBe(false);
}
});
it('blocks SSH to non-git hosts on all SSH ports', async () => {
for (const port of [22, 2222, 7999]) {
const result = await checkBlocked(
'random-server.com',
port,
blocked,
noLD,
noLDCache,
defaultOptions
);
expect(result).toEqual({ blocked: true, reason: 'ssh-non-git-host' });
}
});
it('blocks all SSH when allowSSH is false', async () => {
const result = await checkBlocked('github.com', 22, blocked, noLD, noLDCache, {
allowSSH: false,
});
expect(result).toEqual({ blocked: true, reason: 'ssh-disabled' });
});
it('allows SSH to any host when allowSSHToAllHosts is true', async () => {
const result = await checkBlocked('random-server.com', 22, blocked, noLD, noLDCache, {
allowSSHToAllHosts: true,
});
expect(result.blocked).toBe(false);
});
it('respects custom git hosts list', async () => {
const opts = { gitHosts: ['gitea.internal.com'] };
const allowed = await checkBlocked('gitea.internal.com', 22, blocked, noLD, noLDCache, opts);
expect(allowed.blocked).toBe(false);
const denied = await checkBlocked('github.com', 22, blocked, noLD, noLDCache, opts);
expect(denied).toEqual({ blocked: true, reason: 'ssh-non-git-host' });
});
it('SSH rules only apply to SSH ports', async () => {
const result = await checkBlocked(
'random-server.com',
443,
blocked,
noLD,
noLDCache,
defaultOptions
);
expect(result.blocked).toBe(false);
});
it('checks blocking layers in priority order', async () => {
// loopback before IP
const loopback = await checkBlocked('127.0.0.1', 443, blocked, noLD, noLDCache, {
blockLoopback: true,
allowIPAddresses: false,
});
expect(loopback.reason).toBe('loopback');
// blocklist before SSH
const blocklist = await checkBlocked('evil.com', 22, blocked, noLD, noLDCache, defaultOptions);
expect(blocklist.reason).toBe('blocklist');
});
});
-532
View File
@@ -1,532 +0,0 @@
/**
* HTTP CONNECT proxy with logging, live domain blocklist, and optional
* LaunchDarkly egress control.
*
* Runs in the Electron main process. All outbound traffic from a sandboxed
* goosed process is funneled through this proxy (the macOS seatbelt profile
* blocks direct outbound network, only allowing localhost).
*
* SSH git operations are routed through this proxy via GIT_SSH_COMMAND
* which uses a bundled connect-proxy script as ProxyCommand.
*
* Blocking layers (checked in order):
* 1. Loopback detection (if blockLoopback enabled)
* 2. IP address blocking (if !allowIPAddresses)
* 3. Local blocklist (blocked.txt) — fast, no network, live-reloaded
* 4. SSH/Git host restriction (port 22/2222/7999)
* 5. LaunchDarkly flag ("egress-allowlist") — if configured
*/
import http from 'node:http';
import https from 'node:https';
import net from 'node:net';
import fs from 'node:fs';
import os from 'node:os';
import crypto from 'node:crypto';
import { URL } from 'node:url';
import { Buffer } from 'node:buffer';
const log = {
info: (...args: unknown[]) => console.log('[sandbox-proxy]', ...args),
warn: (...args: unknown[]) => console.warn('[sandbox-proxy]', ...args),
error: (...args: unknown[]) => console.error('[sandbox-proxy]', ...args),
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface LaunchDarklyConfig {
clientId: string;
username?: string;
cacheTtlSeconds?: number;
failoverMode?: 'allow' | 'deny' | 'blocklist';
}
export interface ProxyOptions {
port?: number;
blockedPath?: string;
launchDarkly?: LaunchDarklyConfig;
allowIPAddresses?: boolean;
blockLoopback?: boolean;
allowSSH?: boolean;
gitHosts?: string[];
allowSSHToAllHosts?: boolean;
}
export interface ProxyInstance {
port: number;
server: http.Server;
close: () => Promise<void>;
}
// ---------------------------------------------------------------------------
// Local blocklist
// ---------------------------------------------------------------------------
export function loadBlocked(blockedPath: string | undefined): Set<string> {
if (!blockedPath) return new Set();
try {
if (!fs.existsSync(blockedPath)) return new Set();
const domains = new Set<string>();
for (const line of fs.readFileSync(blockedPath, 'utf-8').split('\n')) {
const trimmed = line.trim().toLowerCase();
if (trimmed && !trimmed.startsWith('#')) {
domains.add(trimmed);
}
}
return domains;
} catch {
return new Set();
}
}
export function normalizeDomain(host: string): string {
let normalized = host.toLowerCase().trim();
if (normalized.endsWith('.')) {
normalized = normalized.slice(0, -1);
}
if (normalized.startsWith('[') && normalized.endsWith(']')) {
normalized = normalized.slice(1, -1);
}
try {
const url = new URL(`http://${normalized}`);
normalized = url.hostname;
} catch {
// use as-is
}
return normalized;
}
export function isIPAddress(host: string): boolean {
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4.test(host)) return true;
if (host.includes(':')) return true;
return false;
}
export function parseConnectTarget(target: string): { host: string; port: number } {
// Handle [ipv6]:port
const bracketMatch = target.match(/^\[([^\]]+)\]:(\d+)$/);
if (bracketMatch) {
return { host: bracketMatch[1], port: parseInt(bracketMatch[2], 10) };
}
// Handle host:port (only split on the last colon to avoid IPv6 issues)
const lastColon = target.lastIndexOf(':');
if (lastColon <= 0) {
return { host: '', port: 0 };
}
const host = target.slice(0, lastColon);
const port = parseInt(target.slice(lastColon + 1), 10);
if (!host || isNaN(port) || port <= 0 || port > 65535) {
return { host: '', port: 0 };
}
return { host, port };
}
const LOOPBACK_RE = /^(localhost|127\.\d+\.\d+\.\d+|::1|\[::1\])$/i;
export function isLoopback(host: string): boolean {
return LOOPBACK_RE.test(host);
}
const DEFAULT_GIT_HOSTS = ['github.com', 'gitlab.com', 'bitbucket.org', 'ssh.dev.azure.com'];
export function matchesBlocked(host: string, blocked: Set<string>): boolean {
const h = normalizeDomain(host);
if (blocked.has(h)) return true;
const parts = h.split('.');
for (let i = 1; i < parts.length; i++) {
const parent = parts.slice(i).join('.');
if (blocked.has(parent)) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// LaunchDarkly client-side evaluation (no SDK — direct REST calls)
// ---------------------------------------------------------------------------
interface LDFlagResult {
value: boolean;
variation?: number;
version?: number;
flagVersion?: number;
}
class TTLCache {
private cache = new Map<string, { value: boolean; ts: number }>();
private ttl: number;
constructor(ttlSeconds: number) {
this.ttl = ttlSeconds * 1000;
}
get(key: string): boolean | undefined {
const entry = this.cache.get(key);
if (!entry) return undefined;
if (Date.now() - entry.ts > this.ttl) {
this.cache.delete(key);
return undefined;
}
return entry.value;
}
put(key: string, value: boolean): void {
this.cache.set(key, { value, ts: Date.now() });
}
}
function httpsRequest(
url: string,
method: string,
headers: Record<string, string>,
body?: string
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const req = https.request(
{
hostname: parsed.hostname,
port: parsed.port || 443,
path: parsed.pathname + parsed.search,
method,
headers,
timeout: 5000,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
resolve({
status: res.statusCode || 0,
body: Buffer.concat(chunks).toString('utf-8'),
});
});
}
);
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
if (body) req.write(body);
req.end();
});
}
async function evaluateLDFlag(
clientId: string,
username: string,
domain: string
): Promise<LDFlagResult | null> {
const url = `https://clientsdk.launchdarkly.com/sdk/evalx/${clientId}/context`;
const context = { kind: 'user', key: domain, username };
try {
const resp = await httpsRequest(
url,
'REPORT',
{ 'Content-Type': 'application/json' },
JSON.stringify(context)
);
const flags = JSON.parse(resp.body);
const flag = flags['egress-allowlist'];
if (!flag || !('value' in flag)) return null;
return flag as LDFlagResult;
} catch {
return null;
}
}
function sendLDEvent(clientId: string, username: string, domain: string, flag: LDFlagResult): void {
// Fire-and-forget — don't await, don't block the proxy
const url = `https://events.launchdarkly.com/events/bulk/${clientId}`;
const ts = Date.now();
const events = [
{
kind: 'index',
creationDate: ts,
context: { kind: 'user', key: domain, username },
},
{
kind: 'summary',
startDate: ts - 60000,
endDate: ts,
features: {
'egress-allowlist': {
default: false,
contextKinds: ['user'],
counters: [
{
variation: flag.variation,
version: flag.version ?? flag.flagVersion,
value: flag.value,
count: 1,
},
],
},
},
},
];
httpsRequest(
url,
'POST',
{
'Content-Type': 'application/json',
'X-LaunchDarkly-Event-Schema': '4',
'X-LaunchDarkly-Payload-ID': crypto.randomUUID(),
},
JSON.stringify(events)
).catch(() => {
// fire-and-forget
});
}
// ---------------------------------------------------------------------------
// Combined blocking check
// ---------------------------------------------------------------------------
export async function checkBlocked(
host: string,
port: number,
blocked: Set<string>,
ldConfig: LaunchDarklyConfig | undefined,
ldCache: TTLCache | undefined,
options: ProxyOptions
): Promise<{ blocked: boolean; reason: string }> {
const normalized = normalizeDomain(host);
if (options.blockLoopback && isLoopback(normalized)) {
log.warn(
`[sandbox-proxy] BLOCK loopback ${host}:${port} — if this breaks a local tool, it may not be respecting no_proxy`
);
return { blocked: true, reason: 'loopback' };
}
if (!options.allowIPAddresses && isIPAddress(normalized)) {
return { blocked: true, reason: 'ip-address' };
}
if (matchesBlocked(normalized, blocked)) {
return { blocked: true, reason: 'blocklist' };
}
if (port === 22 || port === 2222 || port === 7999) {
if (options.allowSSH === false) {
return { blocked: true, reason: 'ssh-disabled' };
}
if (!options.allowSSHToAllHosts) {
const gitHosts = options.gitHosts || DEFAULT_GIT_HOSTS;
const isGitHost = gitHosts.some((gh) => normalized === gh || normalized.endsWith('.' + gh));
if (!isGitHost) {
return { blocked: true, reason: 'ssh-non-git-host' };
}
}
}
if (ldConfig && ldCache) {
const cached = ldCache.get(normalized);
if (cached !== undefined) {
log.info(`[sandbox-proxy] LD:HIT ${host} ${cached ? 'allow' : 'deny'}`);
return { blocked: !cached, reason: cached ? '' : 'launchdarkly (cached)' };
}
const flag = await evaluateLDFlag(
ldConfig.clientId,
ldConfig.username || os.userInfo().username,
normalized
);
if (flag !== null) {
ldCache.put(normalized, flag.value);
const action = flag.value ? 'LD:OK' : 'LD:BLK';
log.info(`[sandbox-proxy] ${action} ${host}`);
sendLDEvent(ldConfig.clientId, ldConfig.username || os.userInfo().username, normalized, flag);
return { blocked: !flag.value, reason: flag.value ? '' : 'launchdarkly' };
}
const failover = ldConfig.failoverMode || 'allow';
if (failover === 'deny') {
log.warn(`[sandbox-proxy] LD:FAILOVER-DENY ${host}`);
return { blocked: true, reason: 'launchdarkly-unreachable' };
}
if (failover === 'blocklist') {
log.warn(`[sandbox-proxy] LD:FAILOVER-BLOCKLIST ${host}`);
if (matchesBlocked(normalized, blocked)) {
return { blocked: true, reason: 'blocklist (LD fallback)' };
}
}
log.info(`[sandbox-proxy] LD:ERR ${host} (defaulting to allow)`);
return { blocked: false, reason: '' };
}
return { blocked: false, reason: '' };
}
// ---------------------------------------------------------------------------
// Proxy server
// ---------------------------------------------------------------------------
export async function startProxy(options: ProxyOptions = {}): Promise<ProxyInstance> {
const { blockedPath, launchDarkly } = options;
const ldCache = launchDarkly ? new TTLCache(launchDarkly.cacheTtlSeconds ?? 3600) : undefined;
let blockedSet = loadBlocked(blockedPath);
let watcher: fs.FSWatcher | undefined;
if (blockedPath) {
try {
watcher = fs.watch(blockedPath, () => {
blockedSet = loadBlocked(blockedPath);
});
} catch {
// file may not exist yet
}
}
const server = http.createServer((req, res) => {
const url = req.url || '';
let host = '';
let reqPort = 80;
try {
const parsed = new URL(url);
host = parsed.hostname || '';
reqPort = parseInt(parsed.port, 10) || 80;
} catch {
host = '';
}
// Use void to handle the async check without making the callback async
void (async () => {
if (host) {
const result = await checkBlocked(
host,
reqPort,
blockedSet,
launchDarkly,
ldCache,
options
);
if (result.blocked) {
log.info(`[sandbox-proxy] BLOCK ${req.method} ${url.slice(0, 120)} (${result.reason})`);
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end(`Blocked by sandbox proxy: ${host}`);
return;
}
}
log.info(`[sandbox-proxy] ALLOW ${req.method} ${url.slice(0, 120)}`);
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
res.writeHead(400);
res.end('Bad request URL');
return;
}
const proxyReq = http.request(
{
hostname: parsedUrl.hostname,
port: parsedUrl.port || 80,
path: parsedUrl.pathname + parsedUrl.search,
method: req.method,
headers: { ...req.headers, host: parsedUrl.host },
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
proxyRes.pipe(res);
}
);
proxyReq.on('error', (err) => {
log.error(`[sandbox-proxy] ERROR ${req.method} ${url.slice(0, 120)}: ${err.message}`);
if (!res.headersSent) {
res.writeHead(502);
res.end(`Proxy error: ${err.message}`);
}
});
req.pipe(proxyReq);
})();
});
// Handle CONNECT for HTTPS tunneling
server.on('connect', (req, clientSocket, head) => {
const target = req.url || '';
const { host, port } = parseConnectTarget(target);
if (!host || !port) {
log.error(`[sandbox-proxy] REJECT CONNECT invalid target: ${target}`);
clientSocket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
clientSocket.destroy();
return;
}
void (async () => {
const result = await checkBlocked(host, port, blockedSet, launchDarkly, ldCache, options);
if (result.blocked) {
log.info(`[sandbox-proxy] BLOCK CONNECT ${target} (${result.reason})`);
clientSocket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
clientSocket.destroy();
return;
}
log.info(`[sandbox-proxy] ALLOW CONNECT ${target}`);
const remoteSocket = net.connect(port, host, () => {
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
if (head.length > 0) {
remoteSocket.write(head);
}
remoteSocket.pipe(clientSocket);
clientSocket.pipe(remoteSocket);
});
remoteSocket.on('error', (err) => {
log.error(`[sandbox-proxy] ERROR CONNECT ${target}: ${err.message}`);
clientSocket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
clientSocket.destroy();
});
clientSocket.on('error', () => {
remoteSocket.destroy();
});
})();
});
return new Promise((resolve, reject) => {
const listenPort = options.port || 0;
// Bind exclusively to IPv4 loopback — the proxy must never be reachable from non-loopback interfaces.
// The sandboxed process connects via HTTP_PROXY=http://127.0.0.1:PORT so IPv6 is not needed.
server.listen(listenPort, '127.0.0.1', () => {
const addr = server.address();
if (!addr || typeof addr === 'string') {
reject(new Error('Failed to get proxy server address'));
return;
}
const actualPort = addr.port;
log.info(`[sandbox-proxy] Listening on 127.0.0.1:${actualPort} (loopback only)`);
if (blockedPath) {
log.info(`[sandbox-proxy] Blocked domains file: ${blockedPath}`);
}
if (launchDarkly) {
log.info(
`[sandbox-proxy] LaunchDarkly: enabled (user=${launchDarkly.username || os.userInfo().username}, flag=egress-allowlist, cache=${launchDarkly.cacheTtlSeconds ?? 3600}s)`
);
}
resolve({
port: actualPort,
server,
close: () =>
new Promise<void>((res) => {
watcher?.close();
server.close(() => res());
}),
});
});
server.on('error', reject);
});
}