diff --git a/crates/goose-server/src/tunnel/lapstone_test.rs b/crates/goose-server/src/tunnel/lapstone_test.rs deleted file mode 100644 index 77cb270c5b..0000000000 --- a/crates/goose-server/src/tunnel/lapstone_test.rs +++ /dev/null @@ -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 { - Json(json!({ - "status": "ok", - "message": "Test server is running" - })) -} - -async fn echo_handler(req: Request) -> Json { - 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(); -} diff --git a/crates/goose-server/src/tunnel/mod.rs b/crates/goose-server/src/tunnel/mod.rs index 6d8c049be9..32a870eddc 100644 --- a/crates/goose-server/src/tunnel/mod.rs +++ b/crates/goose-server/src/tunnel/mod.rs @@ -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}; diff --git a/ui/desktop/forge.config.ts b/ui/desktop/forge.config.ts index a97c286a30..879dc0d01f 100644 --- a/ui/desktop/forge.config.ts +++ b/ui/desktop/forge.config.ts @@ -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: { diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index 4beb3c4dcb..b0a3d96a1d 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -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 logger.error('Error stopping sandbox proxy:', err)); - } - setTimeout(() => { if (goosedProcess && !goosedProcess.killed && process.platform !== 'win32') { goosedProcess.kill('SIGKILL'); diff --git a/ui/desktop/src/sandbox/blocked.txt b/ui/desktop/src/sandbox/blocked.txt deleted file mode 100644 index a4cdfdba85..0000000000 --- a/ui/desktop/src/sandbox/blocked.txt +++ /dev/null @@ -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 diff --git a/ui/desktop/src/sandbox/connect-proxy.pl b/ui/desktop/src/sandbox/connect-proxy.pl deleted file mode 100755 index 86d9c3f1b4..0000000000 --- a/ui/desktop/src/sandbox/connect-proxy.pl +++ /dev/null @@ -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 \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; - } - } -} diff --git a/ui/desktop/src/sandbox/index.ts b/ui/desktop/src/sandbox/index.ts deleted file mode 100644 index 3abfa79509..0000000000 --- a/ui/desktop/src/sandbox/index.ts +++ /dev/null @@ -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 } { - 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 { - 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 { - if (activeProxy) { - await activeProxy.close(); - console.log('[sandbox] Proxy stopped'); - activeProxy = null; - } -} diff --git a/ui/desktop/src/sandbox/proxy.test.ts b/ui/desktop/src/sandbox/proxy.test.ts deleted file mode 100644 index fcf9116093..0000000000 --- a/ui/desktop/src/sandbox/proxy.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/ui/desktop/src/sandbox/proxy.ts b/ui/desktop/src/sandbox/proxy.ts deleted file mode 100644 index bd6149766c..0000000000 --- a/ui/desktop/src/sandbox/proxy.ts +++ /dev/null @@ -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; -} - -// --------------------------------------------------------------------------- -// Local blocklist -// --------------------------------------------------------------------------- - -export function loadBlocked(blockedPath: string | undefined): Set { - if (!blockedPath) return new Set(); - try { - if (!fs.existsSync(blockedPath)) return new Set(); - const domains = new Set(); - 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): 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(); - 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, - 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 { - 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, - 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 { - 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((res) => { - watcher?.close(); - server.close(() => res()); - }), - }); - }); - - server.on('error', reject); - }); -}