chore: cleanup old sandbox (#7700)
This commit is contained in:
@@ -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;
|
pub mod lapstone;
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod lapstone_test;
|
|
||||||
|
|
||||||
use crate::configuration::Settings;
|
use crate::configuration::Settings;
|
||||||
use fs2::FileExt as _;
|
use fs2::FileExt as _;
|
||||||
use goose::config::{paths::Paths, Config};
|
use goose::config::{paths::Paths, Config};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const { resolve } = require('path');
|
|||||||
|
|
||||||
let cfg = {
|
let cfg = {
|
||||||
asar: true,
|
asar: true,
|
||||||
extraResource: ['src/bin', 'src/images', 'src/sandbox'],
|
extraResource: ['src/bin', 'src/images'],
|
||||||
icon: 'src/images/icon',
|
icon: 'src/images/icon',
|
||||||
// Windows specific configuration
|
// Windows specific configuration
|
||||||
win32: {
|
win32: {
|
||||||
|
|||||||
@@ -6,13 +6,7 @@ import { createServer } from 'net';
|
|||||||
import { Buffer } from 'node:buffer';
|
import { Buffer } from 'node:buffer';
|
||||||
import { status } from './api';
|
import { status } from './api';
|
||||||
import { Client, createClient, createConfig } from './api/client';
|
import { Client, createClient, createConfig } from './api/client';
|
||||||
import {
|
|
||||||
buildSandboxSpawn,
|
|
||||||
ensureProxy,
|
|
||||||
stopProxy,
|
|
||||||
isSandboxEnabled,
|
|
||||||
isSandboxAvailable,
|
|
||||||
} from './sandbox';
|
|
||||||
|
|
||||||
export interface Logger {
|
export interface Logger {
|
||||||
info: (...args: unknown[]) => void;
|
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 goosedPath = findGoosedBinaryPath({ isPackaged, resourcesPath });
|
||||||
|
|
||||||
const port = await findAvailablePort();
|
const port = await findAvailablePort();
|
||||||
logger.info(
|
logger.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}`);
|
||||||
`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}${useSandbox ? ' [SANDBOXED]' : ''}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const baseUrl = `https://127.0.0.1:${port}`;
|
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
|
const spawnCommand = goosedPath;
|
||||||
let spawnCommand = goosedPath;
|
const spawnArgs = ['agent'];
|
||||||
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 isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
const spawnOptions = {
|
const spawnOptions = {
|
||||||
@@ -370,10 +346,6 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
|||||||
logger.error('Error while terminating goosed process:', error);
|
logger.error('Error while terminating goosed process:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useSandbox) {
|
|
||||||
stopProxy().catch((err) => logger.error('Error stopping sandbox proxy:', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (goosedProcess && !goosedProcess.killed && process.platform !== 'win32') {
|
if (goosedProcess && !goosedProcess.killed && process.platform !== 'win32') {
|
||||||
goosedProcess.kill('SIGKILL');
|
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