This repository has been archived on 2026-03-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
greywall/internal/sandbox/utils.go
Jose B 6be1cf5620
Some checks failed
Build and test / Lint (pull_request) Failing after 1m3s
Build and test / Test (Linux) (pull_request) Failing after 39s
Build and test / Build (pull_request) Successful in 19s
feat: add domain-based outbound filtering with allowedDomains/deniedDomains
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
2026-02-17 11:52:43 -05:00

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
}