mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat: sandboxing for macos (#7197)
Co-authored-by: Alex Rosenzweig <arosenzweig@squareup.com> Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Generated
+3
-3
@@ -1585,7 +1585,7 @@ dependencies = [
|
||||
"safetensors 0.7.0",
|
||||
"thiserror 2.0.18",
|
||||
"yoke 0.8.1",
|
||||
"zip 7.4.0",
|
||||
"zip 7.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -12491,9 +12491,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "7.4.0"
|
||||
version = "7.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980"
|
||||
checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"indexmap 2.13.0",
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# macOS Sandbox for goosed
|
||||
|
||||
goose includes an optional macOS sandbox that restricts the goosed process using Apple's seatbelt (`sandbox-exec`) and routes all network traffic through a local egress proxy. This limits what the agent can do on your system — blocking sensitive file writes, raw sockets, tunneling tools, and unapproved network destinations.
|
||||
|
||||
> **Requirements:** macOS only. The sandbox relies on `/usr/bin/sandbox-exec` which is only available on macOS.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Set the environment variable before launching the goose desktop app:
|
||||
|
||||
```bash
|
||||
GOOSE_SANDBOX=true
|
||||
```
|
||||
|
||||
Then start the desktop app as normal. goose will:
|
||||
|
||||
1. Generate a seatbelt sandbox profile
|
||||
2. Start a local HTTP CONNECT proxy on localhost
|
||||
3. Launch goosed inside `sandbox-exec`, forcing all traffic through the proxy
|
||||
|
||||
If `sandbox-exec` is not available (e.g. you're on Linux), goose will fail fast with a clear error rather than running unsandboxed.
|
||||
|
||||
## What Gets Restricted
|
||||
|
||||
### File System (seatbelt)
|
||||
|
||||
By default, the sandbox blocks writes to:
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `~/.ssh/` | Prevent SSH key tampering |
|
||||
| `~/.bashrc`, `~/.zshrc`, `~/.bash_profile`, `~/.zprofile` | Prevent shell config injection |
|
||||
| `~/.config/goose/sandbox/` | Protect sandbox config from the sandboxed process |
|
||||
| `~/.config/goose/config.yaml` | Protect goose config |
|
||||
|
||||
### Network (seatbelt)
|
||||
|
||||
All direct network access is denied. The only allowed paths are:
|
||||
|
||||
- **Localhost** — so the process can reach the egress proxy and its own server port
|
||||
- **Unix sockets** — for local IPC
|
||||
- **mDNSResponder** — for DNS resolution
|
||||
|
||||
Everything else must go through the proxy.
|
||||
|
||||
### Process Restrictions (seatbelt)
|
||||
|
||||
- **Tunneling tools blocked:** `nc`, `ncat`, `netcat`, `socat`, `telnet` — prevents the agent from bypassing the proxy
|
||||
- **Raw sockets blocked:** `SOCK_RAW` on `AF_INET`/`AF_INET6` — prevents raw packet crafting
|
||||
- **Kernel extensions blocked:** `system-kext-load` denied
|
||||
|
||||
### Network (proxy)
|
||||
|
||||
The egress proxy checks connections in this order:
|
||||
|
||||
1. **Loopback detection** — prevents using the proxy as a relay back to localhost
|
||||
2. **Raw IP blocking** — connections to bare IP addresses (no domain) are blocked
|
||||
3. **Domain blocklist** — domains listed in `blocked.txt` are denied (including all subdomains)
|
||||
4. **SSH/Git host restrictions** — SSH ports (22, 2222, 7999) are restricted to known git hosts
|
||||
5. **LaunchDarkly allowlist** (optional) — dynamic egress control via feature flag
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is via environment variables. Defaults are designed to be secure out of the box.
|
||||
|
||||
### Core
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `GOOSE_SANDBOX` | `false` | Set to `true` or `1` to enable the sandbox |
|
||||
|
||||
### Seatbelt Profile
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `GOOSE_SANDBOX_PROTECT_FILES` | `true` | Write-protect `~/.ssh` and shell configs. Set to `false` to disable |
|
||||
| `GOOSE_SANDBOX_BLOCK_RAW_SOCKETS` | `true` | Block `SOCK_RAW`. Set to `false` to disable |
|
||||
| `GOOSE_SANDBOX_BLOCK_TUNNELING` | `true` | Block `nc`/`netcat`/`socat`/`telnet`. Set to `false` to disable |
|
||||
|
||||
### Proxy
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `GOOSE_SANDBOX_ALLOW_IP` | `false` | Set to `true` to allow connections to raw IP addresses |
|
||||
| `GOOSE_SANDBOX_BLOCK_LOOPBACK` | `false` | Set to `true` to block loopback relay through the proxy |
|
||||
| `GOOSE_SANDBOX_ALLOW_SSH` | `true` | Set to `false` to block all SSH traffic |
|
||||
| `GOOSE_SANDBOX_GIT_HOSTS` | built-in list | Comma-separated list of allowed SSH git hosts (e.g. `github.com,gitlab.com`) |
|
||||
| `GOOSE_SANDBOX_SSH_ALL_HOSTS` | `false` | Set to `true` to allow SSH to any host (not just git hosts) |
|
||||
|
||||
### LaunchDarkly (optional — not required)
|
||||
|
||||
LaunchDarkly is **not required**. The sandbox works fully without it using the local `blocked.txt` blocklist. These settings only apply if your organization uses LaunchDarkly for dynamic egress control.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LAUNCHDARKLY_CLIENT_ID` | — | LD client SDK key to enable dynamic egress control |
|
||||
| `GOOSE_SANDBOX_LD_FAILOVER` | — | Failover mode if LD is unreachable: `allow`, `deny`, or `blocklist` |
|
||||
|
||||
## Domain Blocklist
|
||||
|
||||
The file `~/.config/goose/sandbox/blocked.txt` controls which domains are blocked by the proxy. It's created automatically on first run from a bundled template.
|
||||
|
||||
```
|
||||
# One domain per line. Subdomains are blocked automatically.
|
||||
# Lines starting with # are comments.
|
||||
evil.com # blocks evil.com and *.evil.com
|
||||
pastebin.com
|
||||
transfer.sh
|
||||
webhook.site
|
||||
```
|
||||
|
||||
**Live reload:** Changes to `blocked.txt` take effect immediately — the proxy watches the file with `fs.watch` and reloads it automatically. No restart needed.
|
||||
|
||||
## SSH and Git
|
||||
|
||||
SSH git operations (`git clone git@github.com:...`) work through the sandbox via a bundled `connect-proxy.pl` script that acts as an SSH `ProxyCommand`. This routes SSH connections through the egress proxy, which then applies the same allowlist rules.
|
||||
|
||||
By default, SSH is only allowed to well-known git hosting domains (GitHub, GitLab, Bitbucket, etc.). To customise:
|
||||
|
||||
```bash
|
||||
# Add custom git hosts
|
||||
export GOOSE_SANDBOX_GIT_HOSTS="github.com,gitlab.com,your-gitea.internal.com"
|
||||
|
||||
# Or allow SSH to all hosts
|
||||
export GOOSE_SANDBOX_SSH_ALL_HOSTS=true
|
||||
```
|
||||
|
||||
## Example Configurations
|
||||
|
||||
### Maximum security
|
||||
|
||||
```bash
|
||||
export GOOSE_SANDBOX=true
|
||||
# All protections enabled (defaults)
|
||||
```
|
||||
|
||||
### Allow raw IP connections (e.g. for internal APIs)
|
||||
|
||||
```bash
|
||||
export GOOSE_SANDBOX=true
|
||||
export GOOSE_SANDBOX_ALLOW_IP=true
|
||||
```
|
||||
|
||||
### Disable SSH entirely
|
||||
|
||||
```bash
|
||||
export GOOSE_SANDBOX=true
|
||||
export GOOSE_SANDBOX_ALLOW_SSH=false
|
||||
```
|
||||
|
||||
### Relaxed mode (sandbox on, fewer restrictions)
|
||||
|
||||
```bash
|
||||
export GOOSE_SANDBOX=true
|
||||
export GOOSE_SANDBOX_PROTECT_FILES=false
|
||||
export GOOSE_SANDBOX_BLOCK_RAW_SOCKETS=false
|
||||
export GOOSE_SANDBOX_BLOCK_TUNNELING=false
|
||||
export GOOSE_SANDBOX_ALLOW_IP=true
|
||||
export GOOSE_SANDBOX_SSH_ALL_HOSTS=true
|
||||
```
|
||||
|
||||
### With LaunchDarkly egress control
|
||||
|
||||
```bash
|
||||
export GOOSE_SANDBOX=true
|
||||
export LAUNCHDARKLY_CLIENT_ID=sdk-your-key-here
|
||||
export GOOSE_SANDBOX_LD_FAILOVER=blocklist # fall back to local blocklist if LD is down
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"GOOSE_SANDBOX=true but sandbox-exec is not available (macOS only)"**
|
||||
You're not on macOS, or `/usr/bin/sandbox-exec` is missing. The sandbox only works on macOS.
|
||||
|
||||
**Extensions or tools can't reach the network**
|
||||
Check if the destination domain is in `~/.config/goose/sandbox/blocked.txt`, or if you need to enable `GOOSE_SANDBOX_ALLOW_IP=true` for IP-based endpoints.
|
||||
|
||||
**Git clone over SSH fails**
|
||||
The target host may not be in the default git hosts allowlist. Add it with `GOOSE_SANDBOX_GIT_HOSTS=your-host.com` or set `GOOSE_SANDBOX_SSH_ALL_HOSTS=true`.
|
||||
|
||||
**Want to inspect what the proxy is blocking?**
|
||||
Check the Electron/goosed logs — blocked connections are logged with the reason.
|
||||
@@ -4,7 +4,7 @@ const { resolve } = require('path');
|
||||
|
||||
let cfg = {
|
||||
asar: true,
|
||||
extraResource: ['src/bin', 'src/images'],
|
||||
extraResource: ['src/bin', 'src/images', 'src/sandbox'],
|
||||
icon: 'src/images/icon',
|
||||
// Windows specific configuration
|
||||
win32: {
|
||||
|
||||
@@ -6,6 +6,13 @@ 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;
|
||||
@@ -222,14 +229,21 @@ 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}`);
|
||||
logger.info(
|
||||
`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}${useSandbox ? ' [SANDBOXED]' : ''}`
|
||||
);
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
const spawnEnv = {
|
||||
const spawnEnv: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
...buildGoosedEnv(port, serverSecret, goosedPath),
|
||||
};
|
||||
@@ -240,6 +254,20 @@ 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 isWindows = process.platform === 'win32';
|
||||
const spawnOptions = {
|
||||
env: spawnEnv,
|
||||
@@ -262,7 +290,7 @@ export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedRe
|
||||
};
|
||||
logger.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2));
|
||||
|
||||
const goosedProcess = spawn(goosedPath, ['agent'], spawnOptions);
|
||||
const goosedProcess = spawn(spawnCommand, spawnArgs, spawnOptions);
|
||||
|
||||
goosedProcess.stdout?.on('data', (data: Buffer) => {
|
||||
logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${data.toString()}`);
|
||||
@@ -311,6 +339,10 @@ 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');
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# 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
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 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"))`,
|
||||
`(deny file-write* (literal "${h}/.config/goose/config.yaml"))`,
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,532 @@
|
||||
/**
|
||||
* 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