mirror of
https://github.com/aaif-goose/goose.git
synced 2026-06-02 06:14:27 +02:00
chore: cleanup old sandbox (#7700)
This commit is contained in:
committed by
Jack Amadeo
parent
ba1e8b8543
commit
3a15b01dcc
@@ -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();
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user