Add NetworkConfig.AllowedDomains and DeniedDomains fields for controlling outbound connections by hostname. Deny rules are checked first (deny wins). When AllowedDomains is set, only matching domains are permitted. When only DeniedDomains is set, all domains except denied ones are allowed. Implement FilteringProxy that wraps gost HTTP proxy with domain enforcement via AllowConnect callback. Skip GreyHaven proxy/DNS defaults
452 lines
14 KiB
Go
452 lines
14 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// ContainsGlobChars checks if a path pattern contains glob characters.
|
|
func ContainsGlobChars(pattern string) bool {
|
|
return strings.ContainsAny(pattern, "*?[]")
|
|
}
|
|
|
|
// RemoveTrailingGlobSuffix removes trailing /** from a path pattern.
|
|
func RemoveTrailingGlobSuffix(pattern string) string {
|
|
return strings.TrimSuffix(pattern, "/**")
|
|
}
|
|
|
|
// NormalizePath normalizes a path for sandbox configuration.
|
|
// Handles tilde expansion and relative paths.
|
|
func NormalizePath(pathPattern string) string {
|
|
home, _ := os.UserHomeDir()
|
|
cwd, _ := os.Getwd()
|
|
|
|
normalized := pathPattern
|
|
|
|
// Expand ~ and relative paths
|
|
switch {
|
|
case pathPattern == "~":
|
|
normalized = home
|
|
case strings.HasPrefix(pathPattern, "~/"):
|
|
normalized = filepath.Join(home, pathPattern[2:])
|
|
case strings.HasPrefix(pathPattern, "./"), strings.HasPrefix(pathPattern, "../"):
|
|
normalized, _ = filepath.Abs(filepath.Join(cwd, pathPattern))
|
|
case !filepath.IsAbs(pathPattern) && !ContainsGlobChars(pathPattern):
|
|
normalized, _ = filepath.Abs(filepath.Join(cwd, pathPattern))
|
|
}
|
|
|
|
// For non-glob patterns, try to resolve symlinks
|
|
if !ContainsGlobChars(normalized) {
|
|
if resolved, err := filepath.EvalSymlinks(normalized); err == nil {
|
|
return resolved
|
|
}
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
// GenerateProxyEnvVars creates environment variables for proxy configuration.
|
|
// Used on macOS where transparent proxying is not available.
|
|
func GenerateProxyEnvVars(proxyURL string) []string {
|
|
envVars := []string{
|
|
"GREYWALL_SANDBOX=1",
|
|
"TMPDIR=/tmp/greywall",
|
|
}
|
|
|
|
if proxyURL == "" {
|
|
return envVars
|
|
}
|
|
|
|
// NO_PROXY for localhost and private networks
|
|
noProxy := strings.Join([]string{
|
|
"localhost",
|
|
"127.0.0.1",
|
|
"::1",
|
|
"*.local",
|
|
".local",
|
|
"169.254.0.0/16",
|
|
"10.0.0.0/8",
|
|
"172.16.0.0/12",
|
|
"192.168.0.0/16",
|
|
}, ",")
|
|
|
|
envVars = append(envVars,
|
|
"NO_PROXY="+noProxy,
|
|
"no_proxy="+noProxy,
|
|
"ALL_PROXY="+proxyURL,
|
|
"all_proxy="+proxyURL,
|
|
"HTTP_PROXY="+proxyURL,
|
|
"HTTPS_PROXY="+proxyURL,
|
|
"http_proxy="+proxyURL,
|
|
"https_proxy="+proxyURL,
|
|
)
|
|
|
|
return envVars
|
|
}
|
|
|
|
// GenerateHTTPProxyEnvVars creates environment variables for an HTTP proxy.
|
|
// Used when domain filtering is active (HTTP CONNECT proxy, not SOCKS5).
|
|
func GenerateHTTPProxyEnvVars(httpProxyURL string) []string {
|
|
envVars := []string{
|
|
"GREYWALL_SANDBOX=1",
|
|
"TMPDIR=/tmp/greywall",
|
|
}
|
|
|
|
if httpProxyURL == "" {
|
|
return envVars
|
|
}
|
|
|
|
// NO_PROXY for localhost and private networks
|
|
noProxy := strings.Join([]string{
|
|
"localhost",
|
|
"127.0.0.1",
|
|
"::1",
|
|
"*.local",
|
|
".local",
|
|
"169.254.0.0/16",
|
|
"10.0.0.0/8",
|
|
"172.16.0.0/12",
|
|
"192.168.0.0/16",
|
|
}, ",")
|
|
|
|
envVars = append(envVars,
|
|
"NO_PROXY="+noProxy,
|
|
"no_proxy="+noProxy,
|
|
"HTTP_PROXY="+httpProxyURL,
|
|
"HTTPS_PROXY="+httpProxyURL,
|
|
"http_proxy="+httpProxyURL,
|
|
"https_proxy="+httpProxyURL,
|
|
)
|
|
|
|
// Inject Node.js proxy bootstrap so fetch() honors HTTP_PROXY.
|
|
// Appends to existing NODE_OPTIONS if set.
|
|
nodeOpts := "--require " + nodeProxyBootstrapPath
|
|
if existing := os.Getenv("NODE_OPTIONS"); existing != "" {
|
|
nodeOpts = existing + " " + nodeOpts
|
|
}
|
|
envVars = append(envVars, "NODE_OPTIONS="+nodeOpts)
|
|
|
|
return envVars
|
|
}
|
|
|
|
// nodeProxyBootstrapJS is the Node.js bootstrap script that makes both
|
|
// fetch() and http/https.request() respect HTTP_PROXY/HTTPS_PROXY env vars.
|
|
//
|
|
// Two mechanisms are needed because Node.js has two separate HTTP stacks:
|
|
// 1. fetch() — powered by undici, patched via EnvHttpProxyAgent
|
|
// 2. http.request()/https.request() — built-in modules, patched via
|
|
// Agent.prototype.createConnection on BOTH http.Agent and https.Agent.
|
|
// This patches the prototype so ALL agent instances (including custom ones
|
|
// created by libraries like node-fetch, axios, got, etc.) tunnel through
|
|
// the proxy — not just globalAgent.
|
|
//
|
|
// The undici setup tries multiple strategies to find the module:
|
|
// 1. require('undici') from CWD
|
|
// 2. createRequire from the main script's path (finds it in the app's node_modules)
|
|
const nodeProxyBootstrapJS = `'use strict';
|
|
(function() {
|
|
var proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY ||
|
|
process.env.https_proxy || process.env.http_proxy;
|
|
if (!proxyUrl) return;
|
|
|
|
// --- Part 1: Patch fetch() via undici EnvHttpProxyAgent ---
|
|
// Strategy: set global dispatcher AND wrap globalThis.fetch to force proxy.
|
|
// This prevents openclaw or other code from overriding the global dispatcher.
|
|
var undiciModule = null;
|
|
var proxyAgent = null;
|
|
|
|
function tryGetUndici(undici) {
|
|
if (undici && typeof undici.EnvHttpProxyAgent === 'function' &&
|
|
typeof undici.setGlobalDispatcher === 'function') {
|
|
return undici;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
try { undiciModule = tryGetUndici(require('undici')); } catch (e) {}
|
|
|
|
if (!undiciModule) {
|
|
try {
|
|
var mainScript = process.argv[1];
|
|
if (mainScript) {
|
|
var createRequire = require('module').createRequire;
|
|
var requireFrom = createRequire(require('path').resolve(mainScript));
|
|
undiciModule = tryGetUndici(requireFrom('undici'));
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
if (undiciModule) {
|
|
proxyAgent = new undiciModule.EnvHttpProxyAgent();
|
|
undiciModule.setGlobalDispatcher(proxyAgent);
|
|
|
|
// Wrap globalThis.fetch to force proxy dispatcher on every call.
|
|
// This prevents code that overrides the global dispatcher from bypassing the proxy.
|
|
if (typeof globalThis.fetch === 'function') {
|
|
var _origFetch = globalThis.fetch;
|
|
globalThis.fetch = function(input, init) {
|
|
process.stderr.write('[greywall:node-bootstrap] fetch: ' + (typeof input === 'string' ? input : (input && input.url ? input.url : '?')) + '\n');
|
|
if (!init) init = {};
|
|
init.dispatcher = proxyAgent;
|
|
return _origFetch.call(globalThis, input, init);
|
|
};
|
|
}
|
|
|
|
// Also wrap undici.fetch and undici.request to catch direct usage
|
|
if (typeof undiciModule.fetch === 'function') {
|
|
var _origUndFetch = undiciModule.fetch;
|
|
undiciModule.fetch = function(input, init) {
|
|
if (!init) init = {};
|
|
init.dispatcher = proxyAgent;
|
|
return _origUndFetch.call(undiciModule, input, init);
|
|
};
|
|
}
|
|
if (typeof undiciModule.request === 'function') {
|
|
var _origUndRequest = undiciModule.request;
|
|
undiciModule.request = function(url, opts) {
|
|
if (!opts) opts = {};
|
|
opts.dispatcher = proxyAgent;
|
|
return _origUndRequest.call(undiciModule, url, opts);
|
|
};
|
|
}
|
|
}
|
|
// --- Shared setup for Parts 2 and 3 ---
|
|
var url = require('url');
|
|
var http = require('http');
|
|
var https = require('https');
|
|
var tls = require('tls');
|
|
|
|
var parsed = new url.URL(proxyUrl);
|
|
var proxyHost = parsed.hostname;
|
|
var proxyPort = parseInt(parsed.port, 10);
|
|
|
|
var noProxyRaw = process.env.NO_PROXY || process.env.no_proxy || '';
|
|
var noProxyList = noProxyRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean);
|
|
|
|
function isIPAddress(h) {
|
|
// IPv4 or IPv6 — skip DNS proxy for raw IPs
|
|
return /^\d{1,3}(\.\d{1,3}){3}$/.test(h) || h.indexOf(':') !== -1;
|
|
}
|
|
|
|
function shouldProxy(hostname) {
|
|
if (!hostname || isIPAddress(hostname)) return false;
|
|
for (var i = 0; i < noProxyList.length; i++) {
|
|
var p = noProxyList[i];
|
|
if (p === hostname) return false;
|
|
if (p.charAt(0) === '.' && hostname.length > p.length &&
|
|
hostname.indexOf(p, hostname.length - p.length) !== -1) return false;
|
|
if (p.charAt(0) === '*' && hostname.length >= p.length - 1 &&
|
|
hostname.indexOf(p.slice(1), hostname.length - p.length + 1) !== -1) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Save originals before patching
|
|
var origHttpCreateConnection = http.Agent.prototype.createConnection;
|
|
var origHttpsCreateConnection = https.Agent.prototype.createConnection;
|
|
|
|
// Direct agent for CONNECT requests to the proxy itself (avoids recursion)
|
|
var directAgent = new http.Agent({ keepAlive: false });
|
|
directAgent.createConnection = origHttpCreateConnection;
|
|
|
|
// --- Part 2: Patch Agent.prototype.createConnection on both http and https ---
|
|
// This ensures ALL agent instances tunnel through the proxy, not just globalAgent.
|
|
// Libraries like node-fetch, axios, got create their own agents — patching the
|
|
// prototype catches them all.
|
|
try {
|
|
// Patch https.Agent.prototype — affects ALL https.Agent instances
|
|
https.Agent.prototype.createConnection = function(options, callback) {
|
|
var targetHost = options.host || options.hostname;
|
|
var targetPort = options.port || 443;
|
|
|
|
if (!shouldProxy(targetHost)) {
|
|
return origHttpsCreateConnection.call(this, options, callback);
|
|
}
|
|
|
|
var connectReq = http.request({
|
|
host: proxyHost,
|
|
port: proxyPort,
|
|
method: 'CONNECT',
|
|
path: targetHost + ':' + targetPort,
|
|
agent: directAgent,
|
|
});
|
|
|
|
connectReq.on('connect', function(res, socket) {
|
|
if (res.statusCode === 200) {
|
|
var tlsSocket = tls.connect({
|
|
socket: socket,
|
|
servername: options.servername || targetHost,
|
|
rejectUnauthorized: options.rejectUnauthorized !== false,
|
|
});
|
|
callback(null, tlsSocket);
|
|
} else {
|
|
socket.destroy();
|
|
callback(new Error('Proxy CONNECT failed: ' + res.statusCode));
|
|
}
|
|
});
|
|
|
|
connectReq.on('error', function(err) { callback(err); });
|
|
connectReq.end();
|
|
};
|
|
|
|
// Patch http.Agent.prototype — affects ALL http.Agent instances
|
|
http.Agent.prototype.createConnection = function(options, callback) {
|
|
var targetHost = options.host || options.hostname;
|
|
var targetPort = options.port || 80;
|
|
|
|
if (!shouldProxy(targetHost)) {
|
|
return origHttpCreateConnection.call(this, options, callback);
|
|
}
|
|
|
|
var connectReq = http.request({
|
|
host: proxyHost,
|
|
port: proxyPort,
|
|
method: 'CONNECT',
|
|
path: targetHost + ':' + targetPort,
|
|
agent: directAgent,
|
|
});
|
|
|
|
connectReq.on('connect', function(res, socket) {
|
|
if (res.statusCode === 200) {
|
|
callback(null, socket);
|
|
} else {
|
|
socket.destroy();
|
|
callback(new Error('Proxy CONNECT failed: ' + res.statusCode));
|
|
}
|
|
});
|
|
|
|
connectReq.on('error', function(err) { callback(err); });
|
|
connectReq.end();
|
|
};
|
|
} catch (e) {}
|
|
|
|
// --- Part 3: Patch dns.lookup / dns.promises.lookup to resolve through proxy ---
|
|
// OpenClaw (and other apps) do DNS resolution before fetch for SSRF protection.
|
|
// Inside the sandbox, DNS is blocked. Route lookups through the proxy's
|
|
// /__greywall_dns endpoint which resolves on the host side.
|
|
try {
|
|
var dns = require('dns');
|
|
var dnsPromises = require('dns/promises');
|
|
var origDnsLookup = dns.lookup;
|
|
var origDnsPromisesLookup = dnsPromises.lookup;
|
|
|
|
function proxyDnsResolve(hostname) {
|
|
return new Promise(function(resolve, reject) {
|
|
var req = http.request({
|
|
host: proxyHost,
|
|
port: proxyPort,
|
|
path: '/__greywall_dns?host=' + encodeURIComponent(hostname),
|
|
method: 'GET',
|
|
agent: directAgent,
|
|
}, function(res) {
|
|
var data = '';
|
|
res.on('data', function(chunk) { data += chunk; });
|
|
res.on('end', function() {
|
|
try {
|
|
var parsed = JSON.parse(data);
|
|
if (parsed.error) {
|
|
var err = new Error(parsed.error);
|
|
err.code = 'ENOTFOUND';
|
|
reject(err);
|
|
} else {
|
|
resolve(parsed.addresses || []);
|
|
}
|
|
} catch(e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
dnsPromises.lookup = function(hostname, options) {
|
|
if (!shouldProxy(hostname)) {
|
|
return origDnsPromisesLookup.call(dnsPromises, hostname, options);
|
|
}
|
|
|
|
return proxyDnsResolve(hostname).then(function(addresses) {
|
|
if (!addresses || addresses.length === 0) {
|
|
var err = new Error('getaddrinfo ENOTFOUND ' + hostname);
|
|
err.code = 'ENOTFOUND';
|
|
throw err;
|
|
}
|
|
|
|
var opts = (typeof options === 'object' && options !== null) ? options : {};
|
|
var family = typeof options === 'number' ? options : (opts.family || 0);
|
|
|
|
var filtered = addresses;
|
|
if (family === 4 || family === 6) {
|
|
filtered = addresses.filter(function(a) { return a.family === family; });
|
|
if (filtered.length === 0) filtered = addresses;
|
|
}
|
|
|
|
if (opts.all) {
|
|
return filtered;
|
|
}
|
|
|
|
return filtered[0];
|
|
});
|
|
};
|
|
|
|
dns.lookup = function(hostname, options, callback) {
|
|
if (typeof options === 'function') {
|
|
callback = options;
|
|
options = {};
|
|
}
|
|
|
|
if (!shouldProxy(hostname)) {
|
|
return origDnsLookup.call(dns, hostname, options, callback);
|
|
}
|
|
|
|
dnsPromises.lookup(hostname, options).then(function(result) {
|
|
if (Array.isArray(result)) {
|
|
callback(null, result);
|
|
} else {
|
|
callback(null, result.address, result.family);
|
|
}
|
|
}, function(err) {
|
|
callback(err);
|
|
});
|
|
};
|
|
|
|
} catch (e) {}
|
|
})();
|
|
`
|
|
|
|
// nodeProxyBootstrapPath is the path where the bootstrap script is written.
|
|
const nodeProxyBootstrapPath = "/tmp/greywall/node-proxy-bootstrap.js"
|
|
|
|
// WriteNodeProxyBootstrap writes the Node.js proxy bootstrap script to disk.
|
|
// Returns the path to the script, or an error if it couldn't be written.
|
|
func WriteNodeProxyBootstrap() (string, error) {
|
|
dir := filepath.Dir(nodeProxyBootstrapPath)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.WriteFile(nodeProxyBootstrapPath, []byte(nodeProxyBootstrapJS), 0o644); err != nil {
|
|
return "", err
|
|
}
|
|
return nodeProxyBootstrapPath, nil
|
|
}
|
|
|
|
// EncodeSandboxedCommand encodes a command for sandbox monitoring.
|
|
func EncodeSandboxedCommand(command string) string {
|
|
if len(command) > 100 {
|
|
command = command[:100]
|
|
}
|
|
return base64.StdEncoding.EncodeToString([]byte(command))
|
|
}
|
|
|
|
// DecodeSandboxedCommand decodes a base64-encoded command.
|
|
func DecodeSandboxedCommand(encoded string) (string, error) {
|
|
data, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(data), nil
|
|
}
|