diff --git a/.claude/commands/ci.md b/.claude/commands/ci.md index 2e9d2f6f..89e83302 100644 --- a/.claude/commands/ci.md +++ b/.claude/commands/ci.md @@ -2,6 +2,8 @@ Commit working changes and push them to CI Run `cargo clippy -- -D warning` before pushing changes. +Also fetch the latest version of the base branch and merge it into the current branch. + Enter loop where you wait for CI to complete, resolve issues, and return to user once CI is green or a major decision is needed to resolve it. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1a8bab51..760c9595 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,7 +36,7 @@ jobs: run: cargo build --verbose - name: Run unit tests - run: cargo nextest run --profile ci --bins --verbose + run: cargo nextest run --profile ci --lib --verbose - name: Run smoke tests run: cargo nextest run --profile ci --test smoke_test --verbose @@ -108,7 +108,7 @@ jobs: - name: Run unit tests run: | source ~/.cargo/env - cargo nextest run --profile ci --bins --verbose + cargo nextest run --profile ci --lib --verbose - name: Run smoke tests run: | diff --git a/CLAUDE.md b/CLAUDE.md index f9f184ac..5d0b03d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,3 +55,17 @@ After modifying code, run `cargo fmt` to ensure consistent formatting before com ## Logging In regular operation of the CLI-only jail (non-server mode), info and warn logs are not permitted as they would interfere with the underlying process output. Only use debug level logs for normal operation and error logs for actual errors. The server mode (`--server`) may use info/warn logs as appropriate since it has no underlying process. + +## CI Debugging + +The Linux CI tests run on a self-hosted runner (`ci-1`) in GCP. Only Coder employees can directly SSH into this instance for debugging. + +To debug CI failures on Linux: +```bash +gcloud --quiet compute ssh root@ci-1 --zone us-central1-f --project httpjail +``` + +The CI workspace is located at `/home/ci/actions-runner/_work/httpjail/httpjail`. Tests run as the `ci` user, not root. When building manually: +```bash +su - ci -c 'cd /home/ci/actions-runner/_work/httpjail/httpjail && cargo test' +``` diff --git a/Cargo.lock b/Cargo.lock index f6cc6e94..bb442510 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "gimli", ] +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + [[package]] name = "adler2" version = "2.0.1" @@ -174,7 +180,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.9", "object", "rustc-demangle", "windows-targets 0.52.6", @@ -206,7 +212,7 @@ dependencies = [ "rustc-hash", "shlex", "syn 2.0.106", - "which", + "which 4.4.2", ] [[package]] @@ -382,6 +388,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "ctrlc" version = "3.5.0" @@ -534,6 +549,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fslock" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.31" @@ -646,6 +671,15 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gzip-header" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2" +dependencies = [ + "crc32fast", +] + [[package]] name = "h2" version = "0.4.12" @@ -761,7 +795,6 @@ dependencies = [ "predicates", "rand", "rcgen", - "regex", "rustls", "serial_test", "tempfile", @@ -771,6 +804,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "v8", "webpki-roots 0.26.11", ] @@ -1143,6 +1177,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +dependencies = [ + "adler", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1326,6 +1369,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem" version = "3.0.5" @@ -2190,6 +2239,23 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "v8" +version = "129.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f276b42044c07ee34aaa7cdc640185148787a78de761c42e8ae0a12af9a9dc6" +dependencies = [ + "bindgen", + "bitflags", + "fslock", + "gzip-header", + "home", + "miniz_oxide 0.7.4", + "once_cell", + "paste", + "which 6.0.3", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2317,6 +2383,40 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.61.2" @@ -2567,6 +2667,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index b92ea3b9..bd907598 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ isolated-cleanup-tests = [] [dependencies] async-trait = "0.1" clap = { version = "4.5", features = ["derive"] } -regex = "1.10" tokio = { version = "1.35", features = ["full"] } hyper = { version = "1.7", features = ["full"] } hyper-util = { version = "0.1", features = ["full"] } @@ -38,6 +37,7 @@ camino = "1.1.11" filetime = "0.2" ctrlc = "3.4" url = "2.5" +v8 = "129" [target.'cfg(target_os = "macos")'.dependencies] nix = { version = "0.27", features = ["user"] } diff --git a/README.md b/README.md index f67bb5d5..67ef4e3f 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ cargo install httpjail - 🔒 **Process-level network isolation** - Isolate processes in restricted network environments - 🌐 **HTTP/HTTPS interception** - Transparent proxy with TLS certificate injection -- 🎯 **Regex-based filtering** - Flexible allow/deny rules with regex patterns - 🔧 **Script-based evaluation** - Custom request evaluation logic via external scripts +- 🚀 **JavaScript evaluation** - Fast, secure request filtering using V8 JavaScript engine - 📝 **Request logging** - Monitor and log all HTTP/HTTPS requests - ⛔ **Default deny** - Requests are blocked unless explicitly allowed - 🖥️ **Cross-platform** - Native support for Linux and macOS @@ -27,32 +27,27 @@ cargo install httpjail ## Quick Start -> By default, httpjail denies all network requests. Add `allow:` rules to permit traffic. +> By default, httpjail denies all network requests. Provide a JS rule or script to allow traffic. ```bash -# Allow only requests to github.com -httpjail -r "allow: github\.com" -r "deny: .*" -- claude +# Allow only requests to github.com (JS) +httpjail --js "r.host === 'github.com'" -- your-app + +# Load JS from a file +echo "/^api\\.example\\.com$/.test(r.host) && r.method === 'GET'" > rules.js +httpjail --js-file rules.js -- curl https://api.example.com/health # Log requests to a file -httpjail --request-log requests.log -r "allow: .*" -- npm install +httpjail --request-log requests.log --js "true" -- npm install # Log format: " <+/-> " (+ = allowed, - = blocked) -# Block specific domains -httpjail -r "deny: telemetry\..*" -r "allow: .*" -- ./my-app - -# Method-specific rules -httpjail -r "allow-get: api\.github\.com" -r "deny: .*" -- git pull - -# Use config file for complex rules -httpjail --config rules.txt -- python script.py - # Use custom script for request evaluation httpjail --script /path/to/check.sh -- ./my-app # Script receives: HTTPJAIL_URL, HTTPJAIL_METHOD, HTTPJAIL_HOST, HTTPJAIL_SCHEME, HTTPJAIL_PATH # Exit 0 to allow, non-zero to block. stdout becomes additional context in 403 response. -# Run as standalone proxy server (no command execution) -httpjail --server -r "allow: .*" +# Run as standalone proxy server (no command execution) and allow all +httpjail --server --js "true" # Server defaults to ports 8080 (HTTP) and 8443 (HTTPS) # Configure your application: # HTTP_PROXY=http://localhost:8080 HTTPS_PROXY=http://localhost:8443 @@ -134,51 +129,44 @@ httpjail creates an isolated network environment for the target process, interce ```bash # Simple allow/deny rules -httpjail -r "allow: api\.github\.com" -r "deny: .*" -- git pull +httpjail --js "r.host === 'api.github.com'" -- git pull -# Multiple allow patterns (order matters!) +# Multiple allow patterns using regex httpjail \ - -r "allow: github\.com" \ - -r "allow: githubusercontent\.com" \ - -r "deny: .*" \ + --js "/github\.com$/.test(r.host) || /githubusercontent\.com$/.test(r.host)" \ -- npm install # Deny telemetry while allowing everything else httpjail \ - -r "deny: telemetry\." \ - -r "deny: analytics\." \ - -r "deny: sentry\." \ - -r "allow: .*" \ + --js "!/telemetry\.|analytics\.|sentry\./.test(r.host)" \ -- ./application # Method-specific rules httpjail \ - -r "allow-get: api\..*\.com" \ - -r "deny-post: telemetry\..*" \ - -r "allow: .*" \ + --js "(r.method === 'GET' && /api\..*\.com$/.test(r.host)) || (r.method === 'POST' && !/telemetry\./.test(r.host)) || r.method !== 'GET' && r.method !== 'POST'" \ -- ./application ``` ### Configuration File -Create a `rules.txt` (one rule per line, `#` comments and blank lines are ignored): +Create a `rules.js` file with your JavaScript evaluation logic: -```text -# rules.txt -allow-get: github\.com -deny: telemetry -allow: .* +```javascript +// rules.js +// Allow GitHub GET requests, block telemetry, allow everything else +(r.method === 'GET' && /github\.com$/.test(r.host)) || +(!/telemetry/.test(r.host)) ``` Use the config: ```bash -httpjail --config rules.txt -- ./my-application +httpjail --js-file rules.js -- ./my-application ``` ### Script-Based Evaluation -Instead of regex rules, you can use a custom script to evaluate each request. The script receives environment variables for each request and returns an exit code to allow (0) or block (non-zero) the request. Any output to stdout becomes additional context in the 403 response. +Instead of writing JavaScript, you can use a custom script to evaluate each request. The script receives environment variables for each request and returns an exit code to allow (0) or block (non-zero) the request. Any output to stdout becomes additional context in the 403 response. ```bash # Simple script example @@ -221,23 +209,74 @@ If `--script` has spaces, it's run through `$SHELL` (default `/bin/sh`); otherwi > [!TIP] > Script-based evaluation can also be used for custom logging! Your script can log requests to a database, send metrics to a monitoring service, or implement complex audit trails before returning the allow/deny decision. +### JavaScript (V8) Evaluation + +httpjail includes first-class support for JavaScript-based request evaluation using Google's V8 engine. This provides flexible and powerful rule logic. + +```bash +# Simple JavaScript expression - allow only GitHub requests +httpjail --js "r.host === 'github.com'" -- curl https://github.com + +# Method-specific filtering +httpjail --js "r.method === 'GET' && r.host === 'api.github.com'" -- git pull + +# Load from file +httpjail --js-file rules.js -- ./my-app + +# Complex logic with multiple conditions (ternary style) +httpjail --js "(r.host.endsWith('github.com') || r.host === 'api.github.com') ? true : (r.host.includes('facebook.com') || r.host.includes('twitter.com')) ? false : (r.scheme === 'https' && r.path.startsWith('/api/')) ? true : false" -- ./my-app + +# Path-based filtering +httpjail --js "r.path.startsWith('/api/') && r.scheme === 'https'" -- npm install + +# Custom block message +httpjail --js "(r.block_message = 'Social media blocked', !r.host.includes('facebook.com'))" -- curl https://facebook.com +``` + +**JavaScript API:** + +All request information is available via the `r` object: +- `r.url` - Full URL being requested (string) +- `r.method` - HTTP method (GET, POST, etc.) +- `r.host` - Hostname from the URL +- `r.scheme` - URL scheme (http or https) +- `r.path` - Path portion of the URL +- `r.block_message` - Optional message to set when denying (writable) + +**JavaScript evaluation rules:** + +- JavaScript expressions evaluate to `true` to allow the request, `false` to block it +- Code is executed in a sandboxed V8 isolate for security +- Syntax errors are caught during startup and cause httpjail to exit +- Runtime errors result in the request being blocked +- Each request evaluation runs in a fresh context for thread safety +- You can set `r.block_message` to provide a custom denial message + +**Performance considerations:** + +- V8 engine provides fast JavaScript execution +- Fresh isolate creation per request ensures thread safety but adds some overhead +- JavaScript evaluation is generally faster than external script execution + +> [!NOTE] +> The `--js` flag conflicts with `--script` and `--js-file`. Only one evaluation method can be used at a time. + ### Advanced Options ```bash # Verbose logging -httpjail -vvv -r "allow: .*" -- curl https://example.com +httpjail -vvv --js "true" -- curl https://example.com # Server mode - run as standalone proxy without executing commands -httpjail --server -r "allow: github\.com" -r "deny: .*" +httpjail --server --js "true" # Server defaults to ports 8080 (HTTP) and 8443 (HTTPS) # Server mode with custom ports (format: port or ip:port) -HTTPJAIL_HTTP_BIND=3128 HTTPJAIL_HTTPS_BIND=3129 httpjail --server -r "allow: .*" +HTTPJAIL_HTTP_BIND=3128 HTTPJAIL_HTTPS_BIND=3129 httpjail --server --js "true" # Configure applications: HTTP_PROXY=http://localhost:3128 HTTPS_PROXY=http://localhost:3129 # Bind to specific interface -HTTPJAIL_HTTP_BIND=192.168.1.100:8080 httpjail --server -r "allow: .*" - +HTTPJAIL_HTTP_BIND=192.168.1.100:8080 httpjail --server --js "true" ``` ### Server Mode @@ -246,16 +285,13 @@ httpjail can run as a standalone proxy server without executing any commands. Th ```bash # Start server with default ports (8080 for HTTP, 8443 for HTTPS) on localhost -httpjail --server -r "allow: github\.com" -r "deny: .*" -# Output: Server running on ports 8080 (HTTP) and 8443 (HTTPS). Press Ctrl+C to stop. +httpjail --server --js "true" # Start server with custom ports using environment variables -HTTPJAIL_HTTP_BIND=3128 HTTPJAIL_HTTPS_BIND=3129 httpjail --server -r "allow: .*" -# Output: Server running on ports 3128 (HTTP) and 3129 (HTTPS). Press Ctrl+C to stop. +HTTPJAIL_HTTP_BIND=3128 HTTPJAIL_HTTPS_BIND=3129 httpjail --server --js "true" # Bind to all interfaces (use with caution - exposes proxy to network) -HTTPJAIL_HTTP_BIND=0.0.0.0:8080 HTTPJAIL_HTTPS_BIND=0.0.0.0:8443 httpjail --server -r "allow: .*" -# Output: Server running on ports 8080 (HTTP) and 8443 (HTTPS). Press Ctrl+C to stop. +HTTPJAIL_HTTP_BIND=0.0.0.0:8080 HTTPJAIL_HTTPS_BIND=0.0.0.0:8443 httpjail --server --js "true" # Configure your applications to use the proxy: export HTTP_PROXY=http://localhost:8080 diff --git a/src/main.rs b/src/main.rs index d25b064e..29a01ce8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,8 +2,9 @@ use anyhow::{Context, Result}; use clap::Parser; use httpjail::jail::{JailConfig, create_jail}; use httpjail::proxy::ProxyServer; +use httpjail::rules::RuleEngine; use httpjail::rules::script::ScriptRuleEngine; -use httpjail::rules::{Action, Rule, RuleEngine}; +use httpjail::rules::v8_js::V8JsRuleEngine; use std::fs::OpenOptions; use std::os::unix::process::ExitStatusExt; use std::sync::atomic::{AtomicBool, Ordering}; @@ -15,44 +16,36 @@ use tracing::{debug, info, warn}; #[command(version = env!("VERSION_WITH_GIT_HASH"), about, long_about = None)] #[command(about = "Monitor and restrict HTTP/HTTPS requests from processes")] struct Args { - /// Rules for filtering requests (can be specified multiple times) - /// Format: "action[-method]: pattern" - /// Examples: - /// -r "allow: github\.com/.*" - /// -r "deny-post: telemetry\..*" - /// -r "allow-get: .*" - /// Actions: allow, deny - /// Methods (optional): get, post, put, delete, head, options, connect, trace, patch - #[arg( - short = 'r', - long = "rule", - value_name = "RULE", - conflicts_with = "script" - )] - rules: Vec, - /// Use script for evaluating requests /// The script receives environment variables: /// HTTPJAIL_URL, HTTPJAIL_METHOD, HTTPJAIL_HOST, HTTPJAIL_SCHEME, HTTPJAIL_PATH /// Exit code 0 allows the request, non-zero blocks it /// stdout becomes additional context in the 403 response + #[arg(short = 's', long = "script", value_name = "PROG")] + script: Option, + + /// Use JavaScript (V8) for evaluating requests + /// The JavaScript code receives global variables: + /// url, method, host, scheme, path + /// Should return true to allow the request, false to block it + /// Example: --js "return host === 'github.com' && method === 'GET'" #[arg( - short = 's', - long = "script", - value_name = "PROG", - conflicts_with = "rules", - conflicts_with = "config" + long = "js", + value_name = "CODE", + conflicts_with = "script", + conflicts_with = "js_file" )] - script: Option, + js: Option, - /// Use configuration file + /// Load JavaScript (V8) rule code from a file + /// Conflicts with --js #[arg( - short = 'c', - long = "config", + long = "js-file", value_name = "FILE", - conflicts_with = "script" + conflicts_with = "script", + conflicts_with = "js" )] - config: Option, + js_file: Option, /// Append requests to a log file #[arg(long = "request-log", value_name = "FILE")] @@ -132,97 +125,6 @@ fn setup_logging(verbosity: u8) { } } -fn parse_rule(rule_str: &str) -> Result { - use hyper::Method; - - // Split on the first colon to separate action from pattern - let parts: Vec<&str> = rule_str.splitn(2, ':').collect(); - if parts.len() != 2 { - anyhow::bail!( - "Invalid rule format: '{}'. Expected 'action[-method]: pattern'", - rule_str - ); - } - - let action_part = parts[0].trim(); - let pattern = parts[1].trim(); - - // Parse action and optional method - let (action, method) = if action_part.contains('-') { - let action_parts: Vec<&str> = action_part.splitn(2, '-').collect(); - let action = match action_parts[0] { - "allow" => Action::Allow, - "deny" => Action::Deny, - _ => anyhow::bail!( - "Invalid action: '{}'. Expected 'allow' or 'deny'", - action_parts[0] - ), - }; - - let method = match action_parts[1].to_lowercase().as_str() { - "get" => Some(Method::GET), - "post" => Some(Method::POST), - "put" => Some(Method::PUT), - "delete" => Some(Method::DELETE), - "head" => Some(Method::HEAD), - "options" => Some(Method::OPTIONS), - "connect" => Some(Method::CONNECT), - "trace" => Some(Method::TRACE), - "patch" => Some(Method::PATCH), - _ => anyhow::bail!("Invalid method: '{}'", action_parts[1]), - }; - - (action, method) - } else { - let action = match action_part { - "allow" => Action::Allow, - "deny" => Action::Deny, - _ => anyhow::bail!( - "Invalid action: '{}'. Expected 'allow' or 'deny'", - action_part - ), - }; - (action, None) - }; - - // Create rule with optional method restriction - let rule = Rule::new(action, pattern)?; - Ok(if let Some(method) = method { - rule.with_methods(vec![method]) - } else { - rule - }) -} - -fn build_rules(args: &Args) -> Result> { - let mut rules = Vec::new(); - - // Load rules from config file if provided - if let Some(config_path) = &args.config { - let contents = std::fs::read_to_string(config_path) - .with_context(|| format!("Failed to read config file: {}", config_path))?; - for line in contents.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - rules.push(parse_rule(line)?); - } - } - - // Parse command line rules in the exact order specified - for rule_str in &args.rules { - rules.push(parse_rule(rule_str)?); - } - - // If no rules specified, the rule engine will deny all requests by default - if rules.is_empty() { - info!("No rules specified; unmatched requests will be denied"); - } - - Ok(rules) -} - /// Direct orphan cleanup without creating jails fn cleanup_orphans() -> Result<()> { use anyhow::Context; @@ -234,77 +136,115 @@ fn cleanup_orphans() -> Result<()> { let canary_dir = PathBuf::from("/tmp/httpjail"); let orphan_timeout = Duration::from_secs(5); // Short timeout to catch recent orphans - debug!("Starting direct orphan cleanup scan in {:?}", canary_dir); + debug!("Starting direct orphan cleanup scan"); - // Check if directory exists - if !canary_dir.exists() { - debug!("Canary directory does not exist, nothing to clean up"); - return Ok(()); - } + // Track jail IDs we've cleaned up to avoid duplicates + #[allow(unused_mut)] // mut is needed on Linux but not macOS + let mut cleaned_jails = std::collections::HashSet::::new(); - // Scan for stale canary files - for entry in fs::read_dir(&canary_dir)? { - let entry = entry?; - let path = entry.path(); + // First, scan for stale canary files + if canary_dir.exists() { + debug!("Scanning canary directory: {:?}", canary_dir); + for entry in fs::read_dir(&canary_dir)? { + let entry = entry?; + let path = entry.path(); - // Skip if not a file - if !path.is_file() { - debug!("Skipping non-file: {:?}", path); - continue; - } + // Skip if not a file + if !path.is_file() { + debug!("Skipping non-file: {:?}", path); + continue; + } - // Check file age using modification time - let metadata = fs::metadata(&path)?; - let modified = metadata - .modified() - .context("Failed to get file modification time")?; - let age = SystemTime::now() - .duration_since(modified) - .unwrap_or(Duration::from_secs(0)); - - debug!("Found canary file {:?} with age {:?}", path, age); - - // If file is older than orphan timeout, clean it up - if age > orphan_timeout { - let jail_id = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - - info!( - "Found orphaned jail '{}' (age: {:?}), cleaning up", - jail_id, age - ); + // Check file age using modification time + let metadata = fs::metadata(&path)?; + let modified = metadata + .modified() + .context("Failed to get file modification time")?; + let age = SystemTime::now() + .duration_since(modified) + .unwrap_or(Duration::from_secs(0)); + + debug!("Found canary file {:?} with age {:?}", path, age); + + // If file is older than orphan timeout, clean it up + if age > orphan_timeout { + let jail_id = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown"); + + info!( + "Found orphaned jail '{}' via canary file (age: {:?}), cleaning up", + jail_id, age + ); + + // Call platform-specific cleanup + #[cfg(target_os = "linux")] + { + ::cleanup_orphaned( + jail_id, + )?; + cleaned_jails.insert(jail_id.to_string()); + } - // Call platform-specific cleanup - #[cfg(target_os = "linux")] - { - ::cleanup_orphaned( - jail_id, - )?; - } + #[cfg(target_os = "macos")] + { + // On macOS, we use WeakJail which doesn't have orphaned resources to clean up + // Just log that we're skipping cleanup + debug!("Skipping orphan cleanup on macOS (using weak jail)"); + } - #[cfg(target_os = "macos")] - { - // On macOS, we use WeakJail which doesn't have orphaned resources to clean up - // Just log that we're skipping cleanup - debug!("Skipping orphan cleanup on macOS (using weak jail)"); + // Remove canary file after cleanup + if let Err(e) = fs::remove_file(&path) { + debug!("Failed to remove canary file {:?}: {}", path, e); + } else { + debug!("Removed canary file: {:?}", path); + } + } else { + debug!( + "Canary file {:?} is not old enough to be considered orphaned", + path + ); } + } + } else { + debug!("Canary directory does not exist"); + } - // Remove canary file after cleanup - if let Err(e) = fs::remove_file(&path) { - debug!("Failed to remove canary file {:?}: {}", path, e); - } else { - debug!("Removed canary file: {:?}", path); + // On Linux, also scan for orphaned namespace configs directly + // This handles cases where canary files were deleted (e.g., /tmp cleanup) + #[cfg(target_os = "linux")] + { + let netns_dir = PathBuf::from("/etc/netns"); + if netns_dir.exists() { + debug!("Scanning for orphaned namespace configs in {:?}", netns_dir); + for entry in fs::read_dir(&netns_dir)? { + let entry = entry?; + let path = entry.path(); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + // Only process httpjail namespace configs + if name.starts_with("httpjail_") && !cleaned_jails.contains(name) { + info!( + "Found orphaned namespace config '{}' without canary file, cleaning up", + name + ); + + ::cleanup_orphaned( + name, + )?; + cleaned_jails.insert(name.to_string()); + } } - } else { - debug!( - "Canary file {:?} is not old enough to be considered orphaned", - path - ); } } + if cleaned_jails.is_empty() { + debug!("No orphaned jails found"); + } else { + info!("Cleaned up {} orphaned jail(s)", cleaned_jails.len()); + } + Ok(()) } @@ -332,14 +272,14 @@ async fn main() -> Result<()> { info!("Starting httpjail in server mode"); } - // Build rule engine based on script or rules + // Build rule engine based on script or JS let request_log = if let Some(path) = &args.request_log { Some(Arc::new(Mutex::new( OpenOptions::new() .create(true) .append(true) .open(path) - .context("Failed to open request log file")?, + .with_context(|| format!("Failed to open request log file: {}", path))?, ))) } else { None @@ -349,9 +289,38 @@ async fn main() -> Result<()> { info!("Using script-based rule evaluation: {}", script); let script_engine = Box::new(ScriptRuleEngine::new(script.clone())); RuleEngine::from_trait(script_engine, request_log) + } else if let Some(js_code) = &args.js { + info!("Using V8 JavaScript rule evaluation"); + let js_engine = match V8JsRuleEngine::new(js_code.clone()) { + Ok(engine) => Box::new(engine), + Err(e) => { + eprintln!("Failed to create V8 JavaScript engine: {}", e); + std::process::exit(1); + } + }; + RuleEngine::from_trait(js_engine, request_log) + } else if let Some(js_file) = &args.js_file { + info!("Using V8 JavaScript rule evaluation from file: {}", js_file); + let code = std::fs::read_to_string(js_file) + .with_context(|| format!("Failed to read JS file: {}", js_file))?; + let js_engine = match V8JsRuleEngine::new(code) { + Ok(engine) => Box::new(engine), + Err(e) => { + eprintln!("Failed to create V8 JavaScript engine: {}", e); + std::process::exit(1); + } + }; + RuleEngine::from_trait(js_engine, request_log) } else { - let rules = build_rules(&args)?; - RuleEngine::new(rules, request_log) + info!("No rule evaluation provided; defaulting to deny-all"); + let js_engine = match V8JsRuleEngine::new("false".to_string()) { + Ok(engine) => Box::new(engine), + Err(e) => { + eprintln!("Failed to create default V8 JavaScript engine: {}", e); + std::process::exit(1); + } + }; + RuleEngine::from_trait(js_engine, request_log) }; // Parse bind configuration from env vars @@ -362,91 +331,56 @@ async fn main() -> Result<()> { // Try to parse as ip:port let ip_str = &val[..colon_pos]; let port_str = &val[colon_pos + 1..]; - - let port = port_str.parse::().ok(); - let ip = ip_str.parse::().ok(); - - if port.is_some() && ip.is_some() { - return (port, ip); + match port_str.parse::() { + Ok(port) => match ip_str.parse::() { + Ok(ip) => (Some(port), Some(ip)), + Err(_) => (Some(port), None), + }, + Err(_) => (None, None), + } + } else { + // Try to parse as port + match val.parse::() { + Ok(port) => (Some(port), None), + Err(_) => (None, None), } } - - // Try to parse as just a port number - if let Ok(port) = val.parse::() { - return (Some(port), None); - } + } else { + (None, None) } - (None, None) } - let (http_port_env, http_bind_ip) = parse_bind_config("HTTPJAIL_HTTP_BIND"); - let (https_port_env, https_bind_ip) = parse_bind_config("HTTPJAIL_HTTPS_BIND"); - - // Use env port or default to 8080/8443 in server mode - let http_port = http_port_env.or(if args.server { Some(8080) } else { None }); - let https_port = https_port_env.or(if args.server { Some(8443) } else { None }); - - // Determine bind address based on configuration and mode - let bind_address = if let Some(ip) = http_bind_ip.or(https_bind_ip) { - // If user explicitly specified an IP, use it - match ip { - std::net::IpAddr::V4(ipv4) => Some(ipv4.octets()), - std::net::IpAddr::V6(_) => { - warn!("IPv6 addresses are not currently supported, falling back to IPv4"); - None - } - } - } else if args.weak || args.server { - // In weak mode or server mode, bind to localhost only by default - None + // Determine ports to bind + let (http_port, _http_ip) = parse_bind_config("HTTPJAIL_HTTP_BIND"); + let (https_port, _https_ip) = parse_bind_config("HTTPJAIL_HTTPS_BIND"); + + // For strong jail mode (not weak, not server), we need to bind to all interfaces (0.0.0.0) + // so the proxy is accessible from the veth interface. For weak mode or server mode, + // localhost is fine. + // TODO: This has security implications - see GitHub issue #31 + let bind_address = if args.weak || args.server { + None // defaults to 127.0.0.1 } else { - // For jailed mode on Linux, bind to all interfaces - // The namespace isolation provides the security boundary - #[cfg(target_os = "linux")] - { - Some([0, 0, 0, 0]) - } - #[cfg(not(target_os = "linux"))] - { - None - } + Some([0, 0, 0, 0]) // bind to all interfaces for strong jail }; + let mut proxy = ProxyServer::new(http_port, https_port, rule_engine, bind_address); - // Start the proxy server - let mut proxy = ProxyServer::new(http_port, https_port, rule_engine.clone(), bind_address); + // Start proxy in background if running as server; otherwise start with random ports let (actual_http_port, actual_https_port) = proxy.start().await?; - info!( - "Proxy server started on ports {} (HTTP) and {} (HTTPS)", - actual_http_port, actual_https_port - ); - - // In server mode, just run the proxy server if args.server { - // Use tokio::sync::Notify for real-time shutdown signaling - let shutdown_notify = Arc::new(tokio::sync::Notify::new()); - let shutdown_notify_clone = shutdown_notify.clone(); - - ctrlc::set_handler(move || { - info!("Received interrupt signal, shutting down server..."); - shutdown_notify_clone.notify_one(); - }) - .expect("Error setting signal handler"); - info!( - "Server running on ports {} (HTTP) and {} (HTTPS). Press Ctrl+C to stop.", + "Proxy server running on http://localhost:{} and https://localhost:{}", actual_http_port, actual_https_port ); - - // Wait for shutdown signal - shutdown_notify.notified().await; - - info!("Server shutdown complete"); - return Ok(()); + std::future::pending::<()>().await; + unreachable!(); } - // Normal mode: create jail and execute command - // Create jail configuration with actual bound ports + // Create jail canary dir early to reduce race with cleanup + std::fs::create_dir_all("/tmp/httpjail").ok(); + + // Configure and execute the target command inside a jail let mut jail_config = JailConfig::new(); jail_config.http_proxy_port = actual_http_port; jail_config.https_proxy_port = actual_https_port; @@ -544,76 +478,3 @@ async fn main() -> Result<()> { Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use hyper::Method; - - #[tokio::test] - async fn test_build_rules_no_rules_default_deny() { - let args = Args { - rules: vec![], - script: None, - config: None, - request_log: None, - weak: false, - verbose: 0, - timeout: None, - no_jail_cleanup: false, - cleanup: false, - server: false, - command: vec![], - }; - - let rules = build_rules(&args).unwrap(); - assert!(rules.is_empty()); - - // Rule engine should deny requests when no rules are specified - let engine = RuleEngine::new(rules, None); - assert!(matches!( - engine.evaluate(Method::GET, "https://example.com").await, - Action::Deny - )); - } - - #[test] - fn test_build_rules_from_config_file() { - use std::io::Write; - use tempfile::NamedTempFile; - - let mut file = NamedTempFile::new().unwrap(); - writeln!( - file, - "allow-get: google\\.com\n# comment\ndeny: yahoo.com\n\nallow: .*" - ) - .unwrap(); - - let args = Args { - rules: vec![], - script: None, - config: Some(file.path().to_str().unwrap().to_string()), - request_log: None, - weak: false, - verbose: 0, - timeout: None, - no_jail_cleanup: false, - cleanup: false, - server: false, - command: vec![], - }; - - let rules = build_rules(&args).unwrap(); - assert_eq!(rules.len(), 3); - - // First rule should be allow for GET method only - assert!(matches!(rules[0].action, Action::Allow)); - assert!(rules[0].methods.as_ref().unwrap().contains(&Method::GET)); - - // Second rule should be deny - assert!(matches!(rules[1].action, Action::Deny)); - - // Third rule allow all - assert!(matches!(rules[2].action, Action::Allow)); - } -} diff --git a/src/proxy.rs b/src/proxy.rs index 06cc2530..0e0add21 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -476,16 +476,14 @@ pub fn create_error_response( #[cfg(test)] mod tests { use super::*; - use crate::rules::Rule; + use crate::rules::v8_js::V8JsRuleEngine; #[tokio::test] async fn test_proxy_server_creation() { - let rules = vec![ - Rule::new(Action::Allow, r"github\.com").unwrap(), - Rule::new(Action::Deny, r".*").unwrap(), - ]; + let js = r"/^github\.com$/.test(r.host)"; + let engine = V8JsRuleEngine::new(js.to_string()).unwrap(); + let rule_engine = RuleEngine::from_trait(Box::new(engine), None); - let rule_engine = RuleEngine::new(rules, None); let proxy = ProxyServer::new(Some(8080), Some(8443), rule_engine, None); assert_eq!(proxy.http_port, Some(8080)); @@ -494,9 +492,8 @@ mod tests { #[tokio::test] async fn test_proxy_server_auto_port() { - let rules = vec![Rule::new(Action::Allow, r".*").unwrap()]; - - let rule_engine = RuleEngine::new(rules, None); + let engine = V8JsRuleEngine::new("true".to_string()).unwrap(); + let rule_engine = RuleEngine::from_trait(Box::new(engine), None); let mut proxy = ProxyServer::new(None, None, rule_engine, None); let (http_port, https_port) = proxy.start().await.unwrap(); diff --git a/src/proxy_tls.rs b/src/proxy_tls.rs index 45ab1ac0..b58fe0e8 100644 --- a/src/proxy_tls.rs +++ b/src/proxy_tls.rs @@ -570,7 +570,6 @@ async fn proxy_https_request( #[cfg(test)] mod tests { use super::*; - use crate::rules::Rule; use rustls::ClientConfig; use std::sync::Arc; use tempfile::TempDir; @@ -592,15 +591,13 @@ mod tests { } fn create_test_rule_engine(allow_all: bool) -> Arc { - let rules = if allow_all { - vec![Rule::new(Action::Allow, r".*").unwrap()] + let js = if allow_all { + "true".to_string() } else { - vec![ - Rule::new(Action::Allow, r"example\.com").unwrap(), - Rule::new(Action::Deny, r".*").unwrap(), - ] + "/example\\.com/.test(r.host)".to_string() }; - Arc::new(RuleEngine::new(rules, None)) + let engine = crate::rules::v8_js::V8JsRuleEngine::new(js).unwrap(); + Arc::new(RuleEngine::from_trait(Box::new(engine), None)) } /// Create a TLS client config that trusts any certificate (for testing) diff --git a/src/rules.rs b/src/rules.rs index dbea7f0b..cf3ca3a0 100644 --- a/src/rules.rs +++ b/src/rules.rs @@ -1,10 +1,9 @@ -pub mod pattern; pub mod script; +pub mod v8_js; use async_trait::async_trait; use chrono::{SecondsFormat, Utc}; use hyper::Method; -pub use pattern::{PatternRuleEngine, Rule}; use std::fs::File; use std::io::Write; use std::sync::{Arc, Mutex}; @@ -96,19 +95,6 @@ pub struct RuleEngine { } impl RuleEngine { - pub fn new(rules: Vec, request_log: Option>>) -> Self { - let pattern_engine = Box::new(PatternRuleEngine::new(rules)); - let engine: Box = if request_log.is_some() { - Box::new(LoggingRuleEngine::new(pattern_engine, request_log)) - } else { - pattern_engine - }; - - RuleEngine { - inner: Arc::from(engine), - } - } - pub fn from_trait( engine: Box, request_log: Option>>, @@ -118,7 +104,6 @@ impl RuleEngine { } else { engine }; - RuleEngine { inner: Arc::from(engine), } @@ -136,93 +121,19 @@ impl RuleEngine { #[cfg(test)] mod tests { use super::*; + use crate::rules::v8_js::V8JsRuleEngine; + use std::fs::OpenOptions; use std::sync::{Arc, Mutex}; - #[test] - fn test_rule_matching() { - let rule = Rule::new(Action::Allow, r"github\.com").unwrap(); - assert!(rule.matches(Method::GET, "https://github.com/user/repo")); - assert!(rule.matches(Method::POST, "http://api.github.com/v3/repos")); - assert!(!rule.matches(Method::GET, "https://gitlab.com/user/repo")); - } - - #[test] - fn test_rule_with_methods() { - let rule = Rule::new(Action::Allow, r"api\.example\.com") - .unwrap() - .with_methods(vec![Method::GET, Method::HEAD]); - - assert!(rule.matches(Method::GET, "https://api.example.com/users")); - assert!(rule.matches(Method::HEAD, "https://api.example.com/users")); - assert!(!rule.matches(Method::POST, "https://api.example.com/users")); - assert!(!rule.matches(Method::DELETE, "https://api.example.com/users")); - } - - #[tokio::test] - async fn test_rule_engine() { - let rules = vec![ - Rule::new(Action::Allow, r"github\.com").unwrap(), - Rule::new(Action::Deny, r"telemetry").unwrap(), - Rule::new(Action::Deny, r".*").unwrap(), - ]; - - let engine = RuleEngine::new(rules, None); - - assert!(matches!( - engine.evaluate(Method::GET, "https://github.com/api").await, - Action::Allow - )); - - assert!(matches!( - engine - .evaluate(Method::POST, "https://telemetry.example.com") - .await, - Action::Deny - )); - - assert!(matches!( - engine.evaluate(Method::GET, "https://example.com").await, - Action::Deny - )); - } - - #[tokio::test] - async fn test_method_specific_rules() { - let rules = vec![ - Rule::new(Action::Allow, r"api\.example\.com") - .unwrap() - .with_methods(vec![Method::GET]), - Rule::new(Action::Deny, r".*").unwrap(), - ]; - - let engine = RuleEngine::new(rules, None); - - assert!(matches!( - engine - .evaluate(Method::GET, "https://api.example.com/data") - .await, - Action::Allow - )); - - assert!(matches!( - engine - .evaluate(Method::POST, "https://api.example.com/data") - .await, - Action::Deny - )); - } - #[tokio::test] async fn test_request_logging() { - use std::fs::OpenOptions; - - let rules = vec![Rule::new(Action::Allow, r".*").unwrap()]; + let engine = V8JsRuleEngine::new("true".to_string()).unwrap(); let log_file = tempfile::NamedTempFile::new().unwrap(); let file = OpenOptions::new() .append(true) .open(log_file.path()) .unwrap(); - let engine = RuleEngine::new(rules, Some(Arc::new(Mutex::new(file)))); + let engine = RuleEngine::from_trait(Box::new(engine), Some(Arc::new(Mutex::new(file)))); engine.evaluate(Method::GET, "https://example.com").await; @@ -232,29 +143,17 @@ mod tests { #[tokio::test] async fn test_request_logging_denied() { - use std::fs::OpenOptions; - - let rules = vec![Rule::new(Action::Deny, r".*").unwrap()]; + let engine = V8JsRuleEngine::new("false".to_string()).unwrap(); let log_file = tempfile::NamedTempFile::new().unwrap(); let file = OpenOptions::new() .append(true) .open(log_file.path()) .unwrap(); - let engine = RuleEngine::new(rules, Some(Arc::new(Mutex::new(file)))); + let engine = RuleEngine::from_trait(Box::new(engine), Some(Arc::new(Mutex::new(file)))); engine.evaluate(Method::GET, "https://blocked.com").await; let contents = std::fs::read_to_string(log_file.path()).unwrap(); assert!(contents.contains("- GET https://blocked.com")); } - - #[tokio::test] - async fn test_default_deny_with_no_rules() { - let engine = RuleEngine::new(vec![], None); - - assert!(matches!( - engine.evaluate(Method::GET, "https://example.com").await, - Action::Deny - )); - } } diff --git a/src/rules/pattern.rs b/src/rules/pattern.rs deleted file mode 100644 index e908fbaa..00000000 --- a/src/rules/pattern.rs +++ /dev/null @@ -1,191 +0,0 @@ -use super::{Action, EvaluationResult, RuleEngineTrait}; -use anyhow::Result; -use async_trait::async_trait; -use hyper::Method; -use regex::Regex; -use std::collections::HashSet; -use tracing::debug; - -#[derive(Debug, Clone)] -pub struct Rule { - pub action: Action, - pub pattern: Regex, - pub methods: Option>, -} - -impl Rule { - pub fn new(action: Action, pattern: &str) -> Result { - Ok(Rule { - action, - pattern: Regex::new(pattern)?, - methods: None, - }) - } - - pub fn with_methods(mut self, methods: Vec) -> Self { - self.methods = Some(methods.into_iter().collect()); - self - } - - pub fn matches(&self, method: Method, url: &str) -> bool { - if !self.pattern.is_match(url) { - return false; - } - - match &self.methods { - None => true, - Some(methods) => methods.contains(&method), - } - } -} - -#[derive(Clone)] -pub struct PatternRuleEngine { - pub rules: Vec, -} - -impl PatternRuleEngine { - pub fn new(rules: Vec) -> Self { - PatternRuleEngine { rules } - } -} - -#[async_trait] -impl RuleEngineTrait for PatternRuleEngine { - async fn evaluate(&self, method: Method, url: &str) -> EvaluationResult { - for rule in &self.rules { - if rule.matches(method.clone(), url) { - match &rule.action { - Action::Allow => { - debug!( - "ALLOW: {} {} (matched: {:?})", - method, - url, - rule.pattern.as_str() - ); - return EvaluationResult::allow() - .with_context(format!("Matched pattern: {}", rule.pattern.as_str())); - } - Action::Deny => { - debug!( - "DENY: {} {} (matched: {:?})", - method, - url, - rule.pattern.as_str() - ); - return EvaluationResult::deny() - .with_context(format!("Matched pattern: {}", rule.pattern.as_str())); - } - } - } - } - - debug!("DENY: {} {} (no matching rules)", method, url); - EvaluationResult::deny().with_context("No matching rules".to_string()) - } - - fn name(&self) -> &str { - "pattern" - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_rule_matching() { - let rule = Rule::new(Action::Allow, r"github\.com").unwrap(); - assert!(rule.matches(Method::GET, "https://github.com/user/repo")); - assert!(rule.matches(Method::POST, "http://api.github.com/v3/repos")); - assert!(!rule.matches(Method::GET, "https://gitlab.com/user/repo")); - } - - #[test] - fn test_rule_with_methods() { - let rule = Rule::new(Action::Allow, r"api\.example\.com") - .unwrap() - .with_methods(vec![Method::GET, Method::HEAD]); - - assert!(rule.matches(Method::GET, "https://api.example.com/users")); - assert!(rule.matches(Method::HEAD, "https://api.example.com/users")); - assert!(!rule.matches(Method::POST, "https://api.example.com/users")); - assert!(!rule.matches(Method::DELETE, "https://api.example.com/users")); - } - - #[tokio::test] - async fn test_pattern_engine() { - let rules = vec![ - Rule::new(Action::Allow, r"github\.com").unwrap(), - Rule::new(Action::Deny, r"telemetry").unwrap(), - Rule::new(Action::Deny, r".*").unwrap(), - ]; - - let engine = PatternRuleEngine::new(rules); - - assert!(matches!( - engine - .evaluate(Method::GET, "https://github.com/api") - .await - .action, - Action::Allow - )); - - assert!(matches!( - engine - .evaluate(Method::POST, "https://telemetry.example.com") - .await - .action, - Action::Deny - )); - - assert!(matches!( - engine - .evaluate(Method::GET, "https://example.com") - .await - .action, - Action::Deny - )); - } - - #[tokio::test] - async fn test_method_specific_rules() { - let rules = vec![ - Rule::new(Action::Allow, r"api\.example\.com") - .unwrap() - .with_methods(vec![Method::GET]), - Rule::new(Action::Deny, r".*").unwrap(), - ]; - - let engine = PatternRuleEngine::new(rules); - - assert!(matches!( - engine - .evaluate(Method::GET, "https://api.example.com/data") - .await - .action, - Action::Allow - )); - - assert!(matches!( - engine - .evaluate(Method::POST, "https://api.example.com/data") - .await - .action, - Action::Deny - )); - } - - #[tokio::test] - async fn test_default_deny_with_no_rules() { - let engine = PatternRuleEngine::new(vec![]); - - assert!(matches!( - engine - .evaluate(Method::GET, "https://example.com") - .await - .action, - Action::Deny - )); - } -} diff --git a/src/rules/v8_js.rs b/src/rules/v8_js.rs new file mode 100644 index 00000000..9a0fa23c --- /dev/null +++ b/src/rules/v8_js.rs @@ -0,0 +1,370 @@ +#[cfg(test)] +use super::Action; +use super::{EvaluationResult, RuleEngineTrait}; +use async_trait::async_trait; +use hyper::Method; +use std::sync::{Once, OnceLock}; +use tracing::{debug, warn}; +use url::Url; + +/// V8 JavaScript rule engine that creates a fresh context for each evaluation +/// to ensure thread safety. While this is less performant than reusing contexts, +/// it's necessary because V8 isolates are not Send + Sync. +pub struct V8JsRuleEngine { + js_code: String, +} + +static V8_INIT: Once = Once::new(); +static V8_PLATFORM: OnceLock> = OnceLock::new(); + +impl V8JsRuleEngine { + /// Creates a new V8 JavaScript rule engine + /// + /// # Arguments + /// * `js_code` - JavaScript expression that evaluates to a boolean value + /// The code has access to the `r` object with properties: + /// - `r.url` - Full URL string + /// - `r.method` - HTTP method string + /// - `r.scheme` - URL scheme (http/https) + /// - `r.host` - Host part of URL + /// - `r.path` - Path part of URL + /// - `r.block_message` - Optional message to set when denying (writable) + pub fn new(js_code: String) -> Result> { + // Initialize V8 platform (only once per process), and keep the platform alive + V8_INIT.call_once(|| { + let platform = v8::new_default_platform(0, false).make_shared(); + v8::V8::initialize_platform(platform.clone()); + // Store platform so it outlives all isolates + let _ = V8_PLATFORM.set(platform); + v8::V8::initialize(); + }); + + // Test that the JavaScript code can be compiled + Self::test_js_compilation(&js_code)?; + + Ok(Self { js_code }) + } + + /// Test that the JavaScript code compiles successfully + fn test_js_compilation(js_code: &str) -> Result<(), Box> { + let mut isolate = v8::Isolate::new(v8::CreateParams::default()); + let handle_scope = &mut v8::HandleScope::new(&mut isolate); + let context = v8::Context::new(handle_scope, Default::default()); + let context_scope = &mut v8::ContextScope::new(handle_scope, context); + + // The code should be a JavaScript expression, not a function + let source = v8::String::new(context_scope, js_code) + .ok_or("Failed to create V8 string from JavaScript code")?; + + v8::Script::compile(context_scope, source, None) + .ok_or("Failed to compile JavaScript expression")?; + + Ok(()) + } + + /// Evaluate the JavaScript rule against the given request + fn execute_js_rule(&self, method: &Method, url: &str) -> (bool, Option) { + let parsed_url = match Url::parse(url) { + Ok(u) => u, + Err(e) => { + debug!("Failed to parse URL '{}': {}", url, e); + return (false, Some(format!("Failed to parse URL: {}", e))); + } + }; + + let scheme = parsed_url.scheme(); + let host = parsed_url.host_str().unwrap_or(""); + let path = parsed_url.path(); + + debug!( + "Executing JS rule for {} {} (host: {}, path: {})", + method, url, host, path + ); + + // Create a new isolate and context for this evaluation + // This ensures thread safety at the cost of some performance + match self.create_and_execute(method.as_str(), url, scheme, host, path) { + Ok(result) => result, + Err(e) => { + warn!("JavaScript execution failed: {}", e); + (false, Some(format!("JavaScript execution failed: {}", e))) + } + } + } + + fn create_and_execute( + &self, + method: &str, + url: &str, + scheme: &str, + host: &str, + path: &str, + ) -> Result<(bool, Option), Box> { + let mut isolate = v8::Isolate::new(v8::CreateParams::default()); + let handle_scope = &mut v8::HandleScope::new(&mut isolate); + let context = v8::Context::new(handle_scope, Default::default()); + let context_scope = &mut v8::ContextScope::new(handle_scope, context); + + let global = context.global(context_scope); + + // Create the 'r' object with jail-related variables + let r_obj = v8::Object::new(context_scope); + let r_key = v8::String::new(context_scope, "r").unwrap(); + global.set(context_scope, r_key.into(), r_obj.into()); + + // Set properties on the 'r' object + if let Some(url_str) = v8::String::new(context_scope, url) { + let key = v8::String::new(context_scope, "url").unwrap(); + r_obj.set(context_scope, key.into(), url_str.into()); + } + + if let Some(method_str) = v8::String::new(context_scope, method) { + let key = v8::String::new(context_scope, "method").unwrap(); + r_obj.set(context_scope, key.into(), method_str.into()); + } + + if let Some(scheme_str) = v8::String::new(context_scope, scheme) { + let key = v8::String::new(context_scope, "scheme").unwrap(); + r_obj.set(context_scope, key.into(), scheme_str.into()); + } + + if let Some(host_str) = v8::String::new(context_scope, host) { + let key = v8::String::new(context_scope, "host").unwrap(); + r_obj.set(context_scope, key.into(), host_str.into()); + } + + if let Some(path_str) = v8::String::new(context_scope, path) { + let key = v8::String::new(context_scope, "path").unwrap(); + r_obj.set(context_scope, key.into(), path_str.into()); + } + + // Initialize block_message as undefined (can be set by user script) + let block_msg_key = v8::String::new(context_scope, "block_message").unwrap(); + let undefined_val = v8::undefined(context_scope); + r_obj.set(context_scope, block_msg_key.into(), undefined_val.into()); + + // Execute the JavaScript expression directly (not wrapped in a function) + let source = + v8::String::new(context_scope, &self.js_code).ok_or("Failed to create V8 string")?; + + let script = v8::Script::compile(context_scope, source, None) + .ok_or("Failed to compile JavaScript expression")?; + + // Execute the expression + let result = script + .run(context_scope) + .ok_or("Expression evaluation failed")?; + + // Convert result to boolean + let allowed = result.boolean_value(context_scope); + + // Get block_message if it was set + let block_msg_key = v8::String::new(context_scope, "block_message").unwrap(); + let block_message = r_obj + .get(context_scope, block_msg_key.into()) + .and_then(|v| { + if v.is_undefined() || v.is_null() { + None + } else { + v.to_string(context_scope) + .map(|s| s.to_rust_string_lossy(context_scope)) + } + }); + + debug!( + "JS rule returned {} for {} {}", + if allowed { "ALLOW" } else { "DENY" }, + method, + url + ); + + if let Some(ref msg) = block_message { + debug!("Block message: {}", msg); + } + + Ok((allowed, block_message)) + } +} + +#[async_trait] +impl RuleEngineTrait for V8JsRuleEngine { + async fn evaluate(&self, method: Method, url: &str) -> EvaluationResult { + // Run the JavaScript evaluation in a blocking task to avoid + // issues with V8's single-threaded nature + let js_code = self.js_code.clone(); + let method_clone = method.clone(); + let url_clone = url.to_string(); + + let (allowed, block_message) = tokio::task::spawn_blocking(move || { + let engine = V8JsRuleEngine { js_code }; + engine.execute_js_rule(&method_clone, &url_clone) + }) + .await + .unwrap_or_else(|e| { + warn!("JavaScript task panicked: {}", e); + (false, Some("JavaScript evaluation task failed".to_string())) + }); + + if allowed { + debug!("ALLOW: {} {} (JS rule allowed)", method, url); + EvaluationResult::allow() + } else { + debug!("DENY: {} {} (JS rule denied)", method, url); + let mut result = EvaluationResult::deny(); + if let Some(msg) = block_message { + result = result.with_context(msg); + } + result + } + } + + fn name(&self) -> &str { + "v8-javascript" + } +} + +// Safe cleanup is handled by V8 itself when isolates are dropped +// No explicit cleanup needed in the Drop implementation + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_js_rule_allow() { + let js_code = r#"r.host === 'github.com'"#.to_string(); + + let engine = V8JsRuleEngine::new(js_code).expect("Failed to create JS engine"); + + let result = engine + .evaluate(Method::GET, "https://github.com/test") + .await; + assert!(matches!(result.action, Action::Allow)); + } + + #[tokio::test] + async fn test_js_rule_deny() { + let js_code = r#"r.host === 'github.com'"#.to_string(); + + let engine = V8JsRuleEngine::new(js_code).expect("Failed to create JS engine"); + + let result = engine + .evaluate(Method::GET, "https://example.com/test") + .await; + assert!(matches!(result.action, Action::Deny)); + } + + #[tokio::test] + async fn test_js_rule_with_method() { + let js_code = r#"r.method === 'GET' && r.host === 'api.github.com'"#.to_string(); + + let engine = V8JsRuleEngine::new(js_code).expect("Failed to create JS engine"); + + let result = engine + .evaluate(Method::GET, "https://api.github.com/v3") + .await; + assert!(matches!(result.action, Action::Allow)); + + let result = engine + .evaluate(Method::POST, "https://api.github.com/v3") + .await; + assert!(matches!(result.action, Action::Deny)); + } + + #[tokio::test] + async fn test_js_rule_with_path() { + let js_code = r#"r.path.startsWith('/api/')"#.to_string(); + + let engine = V8JsRuleEngine::new(js_code).expect("Failed to create JS engine"); + + let result = engine + .evaluate(Method::GET, "https://example.com/api/test") + .await; + assert!(matches!(result.action, Action::Allow)); + + let result = engine + .evaluate(Method::GET, "https://example.com/public/test") + .await; + assert!(matches!(result.action, Action::Deny)); + } + + #[tokio::test] + async fn test_js_rule_complex_logic() { + // Using ternary operator style as mentioned in README + let js_code = r#"(r.host.endsWith('github.com') || r.host === 'api.github.com') ? true : (r.host.includes('facebook.com') || r.host.includes('twitter.com')) ? false : (r.scheme === 'https' && r.path.startsWith('/api/')) ? true : false"#.to_string(); + + let engine = V8JsRuleEngine::new(js_code).expect("Failed to create JS engine"); + + // Test GitHub allow + let result = engine + .evaluate(Method::GET, "https://github.com/user/repo") + .await; + assert!(matches!(result.action, Action::Allow)); + + // Test social media block + let result = engine + .evaluate(Method::GET, "https://facebook.com/profile") + .await; + assert!(matches!(result.action, Action::Deny)); + + // Test API allow + let result = engine + .evaluate(Method::POST, "https://example.com/api/data") + .await; + assert!(matches!(result.action, Action::Allow)); + + // Test default deny + let result = engine + .evaluate(Method::GET, "https://example.com/public") + .await; + assert!(matches!(result.action, Action::Deny)); + } + + #[tokio::test] + async fn test_js_syntax_error() { + let js_code = r#"invalid syntax here !!!"#.to_string(); + + // Should fail during construction due to syntax error + let result = V8JsRuleEngine::new(js_code); + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_js_runtime_error() { + // This will throw a runtime error because undefinedVariable is not defined + let js_code = r#"undefinedVariable.property"#.to_string(); + + let engine = V8JsRuleEngine::new(js_code).expect("Failed to create JS engine"); + + // Should return deny on runtime error + let result = engine + .evaluate(Method::GET, "https://example.com/test") + .await; + assert!(matches!(result.action, Action::Deny)); + } + + #[tokio::test] + async fn test_js_block_message() { + // Test setting a custom block message + let js_code = r#"(r.block_message = 'Access to social media is blocked', r.host.includes('facebook.com') ? false : true)"#.to_string(); + + let engine = V8JsRuleEngine::new(js_code).expect("Failed to create JS engine"); + + // Should block facebook with custom message + let result = engine + .evaluate(Method::GET, "https://facebook.com/test") + .await; + assert!(matches!(result.action, Action::Deny)); + assert_eq!( + result.context, + Some("Access to social media is blocked".to_string()) + ); + + // Should allow others without message + let result = engine + .evaluate(Method::GET, "https://example.com/test") + .await; + assert!(matches!(result.action, Action::Allow)); + assert_eq!(result.context, None); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 7a015843..6ba45534 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -50,10 +50,10 @@ impl HttpjailCommand { self } - /// Add a rule - pub fn rule(mut self, rule: &str) -> Self { - self.args.push("-r".to_string()); - self.args.push(rule.to_string()); + /// Provide JavaScript rule code directly + pub fn js(mut self, code: &str) -> Self { + self.args.push("--js".to_string()); + self.args.push(code.to_string()); self } @@ -134,23 +134,8 @@ impl HttpjailCommand { let exit_code = output.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - Ok((exit_code, stdout, stderr)) } - - /// Build the command without executing (for debugging) - #[allow(dead_code)] - pub fn build(mut self) -> Vec { - // Always add timeout for tests - self.args.insert(0, "--timeout".to_string()); - self.args.insert(1, "10".to_string()); - - if self.weak_mode { - self.args.insert(0, "--weak".to_string()); - } - - self.args - } } /// Check if running with sudo @@ -163,7 +148,7 @@ pub fn has_sudo() -> bool { // Common test implementations that can be used by both weak and strong mode tests -/// Test that HTTPS is blocked correctly +/// Test that HTTPS is blocked correctly pub fn test_https_blocking(use_sudo: bool) { let mut cmd = HttpjailCommand::new(); @@ -174,7 +159,7 @@ pub fn test_https_blocking(use_sudo: bool) { } let result = cmd - .rule("deny: .*") + .js("false") .verbose(2) .command(vec!["curl", "-k", "--max-time", "3", "https://ifconfig.me"]) .execute(); @@ -207,7 +192,7 @@ pub fn test_https_blocking(use_sudo: bool) { } } -/// Test that HTTPS is allowed with proper allow rules +/// Test that HTTPS is allowed with proper JS rule pub fn test_https_allow(use_sudo: bool) { let mut cmd = HttpjailCommand::new(); @@ -218,7 +203,7 @@ pub fn test_https_allow(use_sudo: bool) { } let result = cmd - .rule("allow: ifconfig\\.me") + .js("/ifconfig\\.me/.test(r.host)") .verbose(2) .command(vec!["curl", "-k", "--max-time", "8", "https://ifconfig.me"]) .execute(); @@ -229,24 +214,18 @@ pub fn test_https_allow(use_sudo: bool) { println!("Stdout: {}", stdout); println!("Stderr: {}", stderr); - // For macOS native mode, TLS interception might have issues - // So we check that the request was at least allowed (not denied with 403) if use_sudo { - // In sudo mode, just verify it wasn't blocked assert!( !stderr.contains("403 Forbidden") && !stderr.contains("Request blocked"), "Request should not be blocked when allowed" ); } else { - // In weak mode, curl should succeed assert_eq!( exit_code, 0, "Expected curl to succeed in weak mode, got exit code: {}", exit_code ); - // Should contain actual response content - // ifconfig.me returns an IP address use std::str::FromStr; assert!( std::net::Ipv4Addr::from_str(stdout.trim()).is_ok() diff --git a/tests/linux_integration.rs b/tests/linux_integration.rs index 1235649e..029f9c6a 100644 --- a/tests/linux_integration.rs +++ b/tests/linux_integration.rs @@ -59,8 +59,8 @@ mod tests { // Run httpjail let mut cmd = httpjail_cmd(); - cmd.arg("-r") - .arg("allow: .*") + cmd.arg("--js") + .arg("true") .arg("--") .arg("echo") .arg("test"); @@ -140,8 +140,8 @@ mod tests { // 2. Run httpjail command let mut cmd = httpjail_cmd(); - cmd.arg("-r") - .arg("allow: .*") + cmd.arg("--js") + .arg("true") .arg("--") .arg("echo") .arg("test"); @@ -240,8 +240,8 @@ mod tests { // Start httpjail with a long-running command using std::process::Command directly let httpjail_path = assert_cmd::cargo::cargo_bin("httpjail"); let mut child = std::process::Command::new(&httpjail_path) - .arg("-r") - .arg("allow: .*") + .arg("--js") + .arg("true") .arg("--") .arg("sleep") .arg("60") @@ -292,7 +292,7 @@ mod tests { // Attempt to connect to portquiz.net on port 81 (non-standard HTTP port) // Expectation: connection is blocked by namespace egress filter let mut cmd = httpjail_cmd(); - cmd.arg("-r").arg("allow: .*") // proxy allows HTTP/HTTPS, but port 81 should be blocked + cmd.arg("--js").arg("true") // allow all requests through proxy .arg("--") .arg("sh") .arg("-c") diff --git a/tests/smoke_test.rs b/tests/smoke_test.rs index 2c3d8a69..56037d3d 100644 --- a/tests/smoke_test.rs +++ b/tests/smoke_test.rs @@ -24,7 +24,8 @@ fn test_httpjail_version() { #[test] fn test_httpjail_requires_command() { let mut cmd = Command::cargo_bin("httpjail").unwrap(); - cmd.arg("-r").arg("allow: .*"); + // original command: cmd.arg("-r").arg("exit 1;"); + cmd.arg("--js").arg("true"); cmd.assert() .failure() @@ -32,15 +33,16 @@ fn test_httpjail_requires_command() { } #[test] -fn test_httpjail_invalid_regex() { +fn test_httpjail_invalid_js_syntax() { let mut cmd = Command::cargo_bin("httpjail").unwrap(); - cmd.arg("-r") - .arg("allow: [invalid regex") + // original testing with invalid regex: cmd.arg("-r").arg("invalid[regex"); + cmd.arg("--js") + .arg("invalid syntax !!!") .arg("--") .arg("echo") .arg("test"); - // Should fail due to invalid regex + // Should fail due to invalid JS cmd.assert().failure(); } diff --git a/tests/system_integration.rs b/tests/system_integration.rs index b6d57cd3..48870fa7 100644 --- a/tests/system_integration.rs +++ b/tests/system_integration.rs @@ -127,7 +127,9 @@ pub fn test_jail_allows_matching_requests() { // httpjail_cmd() already sets timeout let mut cmd = httpjail_cmd(); - cmd.arg("-r").arg("allow: ifconfig\\.me").arg("--"); + cmd.arg("--js") + .arg("/ifconfig\\.me/.test(r.host)") + .arg("--"); curl_http_status_args(&mut cmd, "http://ifconfig.me"); let output = cmd.output().expect("Failed to execute httpjail"); @@ -147,7 +149,9 @@ pub fn test_jail_denies_non_matching_requests() { P::require_privileges(); let mut cmd = httpjail_cmd(); - cmd.arg("-r").arg("allow: ifconfig\\.me").arg("--"); + cmd.arg("--js") + .arg("/ifconfig\\.me/.test(r.host)") + .arg("--"); curl_http_status_args(&mut cmd, "http://example.com"); let output = cmd.output().expect("Failed to execute httpjail"); @@ -170,7 +174,9 @@ pub fn test_jail_method_specific_rules() { // Test 1: Allow GET to ifconfig.me let mut cmd = httpjail_cmd(); - cmd.arg("-r").arg("allow-get: ifconfig\\.me").arg("--"); + cmd.arg("--js") + .arg("/ifconfig\\.me/.test(r.host) && r.method === 'GET'") + .arg("--"); curl_http_method_status_args(&mut cmd, "GET", "http://ifconfig.me"); let output = cmd.output().expect("Failed to execute httpjail"); @@ -185,7 +191,9 @@ pub fn test_jail_method_specific_rules() { // Test 2: Deny POST to same URL (ifconfig.me) let mut cmd = httpjail_cmd(); - cmd.arg("-r").arg("allow-get: ifconfig\\.me").arg("--"); + cmd.arg("--js") + .arg("/ifconfig\\.me/.test(r.host) && r.method === 'GET'") + .arg("--"); curl_http_method_status_args(&mut cmd, "POST", "http://ifconfig.me"); let output = cmd.output().expect("Failed to execute httpjail"); @@ -205,8 +213,8 @@ pub fn test_jail_request_log() { let mut cmd = httpjail_cmd(); cmd.arg("--request-log") .arg(&log_path) - .arg("-r") - .arg("allow: .*") + .arg("--js") + .arg("true") .arg("--"); curl_http_status_args(&mut cmd, "http://example.com"); @@ -224,8 +232,8 @@ pub fn test_jail_request_log() { let mut cmd = httpjail_cmd(); cmd.arg("--request-log") .arg(&log_path) - .arg("-r") - .arg("deny: .*") + .arg("--js") + .arg("false") .arg("--"); curl_http_status_args(&mut cmd, "http://example.com"); @@ -242,7 +250,7 @@ pub fn test_jail_request_log() { pub fn test_jail_requires_command() { // This test doesn't require root let mut cmd = httpjail_cmd(); - cmd.arg("-r").arg("allow: .*"); + cmd.arg("--js").arg("true"); cmd.assert().failure().stderr(predicate::str::contains( "required arguments were not provided", @@ -255,8 +263,8 @@ pub fn test_jail_exit_code_propagation() { // Test that httpjail propagates the exit code of the child process let mut cmd = httpjail_cmd(); - cmd.arg("-r") - .arg("allow: .*") + cmd.arg("--js") + .arg("true") .arg("--") .arg("sh") .arg("-c") @@ -293,10 +301,8 @@ pub fn test_native_jail_blocks_https() { let mut cmd = httpjail_cmd(); cmd.arg("-v") .arg("-v") // Add verbose logging - .arg("-r") - .arg("allow: ifconfig\\.me") - .arg("-r") - .arg("deny: example\\.com") + .arg("--js") + .arg("/ifconfig\\.me/.test(r.host) && !/example\\.com/.test(r.host)") .arg("--"); curl_https_head_args(&mut cmd, "https://example.com"); @@ -345,7 +351,9 @@ pub fn test_native_jail_allows_https() { // Test allowing HTTPS to ifconfig.me let mut cmd = httpjail_cmd(); - cmd.arg("-r").arg("allow: ifconfig\\.me").arg("--"); + cmd.arg("--js") + .arg("/ifconfig\\.me/.test(r.host)") + .arg("--"); curl_https_status_args(&mut cmd, "https://ifconfig.me"); let output = cmd.output().expect("Failed to execute httpjail"); @@ -402,8 +410,8 @@ pub fn test_jail_privilege_dropping() { // Run whoami through httpjail let mut cmd = httpjail_cmd(); - cmd.arg("-r") - .arg("allow: .*") // Allow all for this test + cmd.arg("--js") + .arg("true") // Allow all for this test .arg("--") .arg("whoami"); @@ -432,11 +440,7 @@ pub fn test_jail_privilege_dropping() { // Also verify that id command shows correct user let mut cmd = httpjail_cmd(); - cmd.arg("-r") - .arg("allow: .*") - .arg("--") - .arg("id") - .arg("-un"); // Get username from id + cmd.arg("--js").arg("true").arg("--").arg("id").arg("-un"); // Get username from id let output = cmd.output().expect("Failed to execute httpjail"); let id_user = String::from_utf8_lossy(&output.stdout).trim().to_string(); @@ -458,10 +462,8 @@ pub fn test_jail_https_connect_denied() { let mut cmd = httpjail_cmd(); cmd.arg("-v") .arg("-v") // Add verbose logging - .arg("-r") - .arg("allow: ifconfig\\.me") - .arg("-r") - .arg("deny: example\\.com") + .arg("--js") + .arg("/ifconfig\\.me/.test(r.host) && !/example\\.com/.test(r.host)") .arg("--"); curl_https_head_args(&mut cmd, "https://example.com"); @@ -502,8 +504,8 @@ pub fn test_jail_network_diagnostics() { // Basic connectivity check - verify network is set up let mut cmd = httpjail_cmd(); - cmd.arg("-r") - .arg("allow: .*") + cmd.arg("--js") + .arg("true") .arg("--") .arg("sh") .arg("-c") @@ -525,8 +527,8 @@ pub fn test_jail_dns_resolution() { // Try to resolve google.com using dig or nslookup let mut cmd = httpjail_cmd(); - cmd.arg("-r") - .arg("allow: .*") + cmd.arg("--js") + .arg("true") .arg("--") .arg("sh") .arg("-c") @@ -583,10 +585,8 @@ pub fn test_concurrent_jail_isolation() { let child1 = std::process::Command::new(&httpjail_path) .arg("-v") .arg("-v") // Add verbose logging to fix timing issues - .arg("-r") - .arg("allow: ifconfig\\.me") - .arg("-r") - .arg("deny: .*") + .arg("--js") + .arg("/ifconfig\\.me/.test(r.host)") .arg("--") .arg("sh") .arg("-c") @@ -603,10 +603,8 @@ pub fn test_concurrent_jail_isolation() { let output2 = std::process::Command::new(&httpjail_path) .arg("-v") .arg("-v") // Add verbose logging to fix timing issues - .arg("-r") - .arg("allow: ifconfig\\.io") - .arg("-r") - .arg("deny: .*") + .arg("--js") + .arg("/ifconfig\\.io/.test(r.host)") .arg("--") .arg("sh") .arg("-c") diff --git a/tests/weak_integration.rs b/tests/weak_integration.rs index 28765d9c..30e8ada0 100644 --- a/tests/weak_integration.rs +++ b/tests/weak_integration.rs @@ -23,7 +23,7 @@ fn test_weak_mode_blocks_http_correctly() { // Test that HTTP to ifconfig.me is blocked in weak mode let result = HttpjailCommand::new() .weak() - .rule("deny: .*") + .js("false") .verbose(2) .command(vec!["curl", "--max-time", "3", "http://ifconfig.me"]) .execute(); @@ -60,9 +60,10 @@ fn test_weak_mode_timeout_works() { // This test uses a command that would normally hang let result = HttpjailCommand::new() .weak() - .rule("allow: .*") + .js("true") .verbose(2) - .command(vec!["sh", "-c", "sleep 15"]) + .command(vec!["bash", "-c", "sleep 60"]) + // command that exceeds timeout .execute(); match result { @@ -85,15 +86,10 @@ fn test_weak_mode_allows_localhost() { // Test that localhost connections work (for the proxy itself) let result = HttpjailCommand::new() .weak() - .rule("allow: localhost") - .rule("allow: 127\\.0\\.0\\.1") - .verbose(2) - .command(vec![ - "curl", - "--max-time", - "3", - "http://127.0.0.1:8080/test", - ]) + .js("r.host === 'localhost' || r.host === '127.0.0.1'") + .verbose(1) + .command(vec!["curl", "--max-time", "3", "http://localhost:80"]) + // may fail but should be allowed by rules .execute(); match result { @@ -128,7 +124,7 @@ fn test_weak_mode_appends_no_proxy() { // Ensure existing NO_PROXY values are preserved and localhost entries appended let result = HttpjailCommand::new() .weak() - .rule("allow: .*") + .js("true") .env("NO_PROXY", "example.com") .verbose(2) .command(vec!["env"]) @@ -180,8 +176,8 @@ fn start_server(http_port: u16, https_port: u16) -> Result