Skip to content

fix(security): default webhook to loopback when tunnel active#1194

Merged
ilblackdragon merged 2 commits intostagingfrom
fix/webhook-loopback-with-tunnel
Mar 15, 2026
Merged

fix(security): default webhook to loopback when tunnel active#1194
ilblackdragon merged 2 commits intostagingfrom
fix/webhook-loopback-with-tunnel

Conversation

@ilblackdragon
Copy link
Copy Markdown
Member

Summary

  • When a tunnel (ngrok, cloudflare, tailscale, custom, or static TUNNEL_URL) is configured, the webhook server now defaults HTTP_HOST to 127.0.0.1 instead of 0.0.0.0
  • External traffic arrives through the tunnel to localhost, so binding all interfaces is unnecessary attack surface
  • Explicit HTTP_HOST env var still overrides the default in all cases

Test plan

  • http_host_defaults_to_loopback_with_tunnel — tunnel active + no explicit host = 127.0.0.1
  • http_host_defaults_to_all_interfaces_without_tunnel — no tunnel = 0.0.0.0 (unchanged behavior)
  • explicit_http_host_overrides_tunnel_default — explicit HTTP_HOST always wins
  • cargo clippy --all --all-features — zero warnings
  • All 13 channel config tests pass

🤖 Generated with Claude Code

…igured

When a tunnel provider (ngrok, cloudflare, tailscale, etc.) or static
TUNNEL_URL is configured, external traffic arrives through the tunnel,
so binding 0.0.0.0 is unnecessary attack surface. The webhook server
now defaults to 127.0.0.1 when a tunnel is active. Explicit HTTP_HOST
still overrides the default in all cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 15, 2026 06:03
@github-actions github-actions Bot added scope: channel/cli TUI / CLI channel size: M 50-199 changed lines risk: low Changes to docs, tests, or low-risk modules labels Mar 15, 2026
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request implements a security improvement by adjusting the default binding address for the webhook server. When a tunnel is detected as active, the server will now default to listening on the loopback interface (127.0.0.1), minimizing unnecessary exposure to the network. This change ensures that the application adheres to the principle of least privilege by only binding to 0.0.0.0 when a tunnel is not in use, while still allowing explicit host configurations to take precedence.

Highlights

  • Security Enhancement: The webhook server's default HTTP_HOST now binds to 127.0.0.1 (loopback) instead of 0.0.0.0 (all interfaces) when a tunnel (e.g., ngrok, Cloudflare) is active. This reduces the attack surface by ensuring the server only listens for local connections when external traffic is routed through a secure tunnel.
  • Configuration Logic: The system now explicitly checks for an active tunnel during configuration resolution to apply the appropriate default HTTP_HOST.
  • Override Mechanism: Existing functionality to explicitly set HTTP_HOST via an environment variable remains unchanged and will always override the new default behavior.
  • Test Coverage: New unit tests were added to verify the correct default HTTP_HOST behavior under various tunnel and explicit host configurations.
Changelog
  • src/cli/doctor.rs
    • Modified check_gateway_config to resolve TunnelConfig and pass its is_enabled() status to ChannelsConfig::resolve.
  • src/config/channels.rs
    • Updated ChannelsConfig::resolve to accept a tunnel_enabled parameter.
    • Implemented conditional logic to set the default HTTP_HOST to 127.0.0.1 if tunnel_enabled is true, otherwise 0.0.0.0.
    • Added three new unit tests to cover the default HTTP_HOST behavior with and without tunnels, and with explicit overrides.
  • src/config/mod.rs
    • Adjusted the Config::build method to resolve TunnelConfig before ChannelsConfig.
    • Passed the tunnel.is_enabled() result to the ChannelsConfig::resolve call.
    • Reordered the tunnel and channels fields in the Config struct initialization.
Activity
  • No human activity has been recorded on this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a sensible security enhancement by defaulting the webhook server to bind to the loopback interface (127.0.0.1) when a tunnel is active, reducing unnecessary attack surface. The implementation correctly propagates the tunnel status through the configuration layers. My review includes one high-severity comment regarding the new tests, which manipulate shared environment variables without proper locking, potentially causing race conditions and test flakiness, in violation of the rule regarding serialization of tests modifying shared global state. I've provided a code suggestion to fix this by adding the required mutex and ensuring consistent test cleanup.

Comment thread src/config/channels.rs
Comment on lines +365 to +426
/// When a tunnel is active and HTTP_HOST is not explicitly set, the
/// webhook server should default to loopback to avoid unnecessary exposure.
#[test]
fn http_host_defaults_to_loopback_with_tunnel() {
// Set HTTP_PORT to trigger HttpConfig creation, but leave HTTP_HOST unset
// so the default kicks in.
unsafe {
std::env::set_var("HTTP_PORT", "9999");
std::env::remove_var("HTTP_HOST");
}
let settings = crate::settings::Settings::default();
let cfg = ChannelsConfig::resolve(&settings, true).unwrap();
unsafe {
std::env::remove_var("HTTP_PORT");
}
let http = cfg.http.expect("HttpConfig should be present");
assert_eq!(
http.host, "127.0.0.1",
"tunnel active should default to loopback"
);
assert_eq!(http.port, 9999);
}

/// Without a tunnel, the webhook server defaults to 0.0.0.0 so external
/// services can reach it directly.
#[test]
fn http_host_defaults_to_all_interfaces_without_tunnel() {
unsafe {
std::env::set_var("HTTP_PORT", "9998");
std::env::remove_var("HTTP_HOST");
}
let settings = crate::settings::Settings::default();
let cfg = ChannelsConfig::resolve(&settings, false).unwrap();
unsafe {
std::env::remove_var("HTTP_PORT");
}
let http = cfg.http.expect("HttpConfig should be present");
assert_eq!(
http.host, "0.0.0.0",
"no tunnel should default to all interfaces"
);
}

/// An explicit HTTP_HOST always wins regardless of tunnel state.
#[test]
fn explicit_http_host_overrides_tunnel_default() {
unsafe {
std::env::set_var("HTTP_PORT", "9997");
std::env::set_var("HTTP_HOST", "192.168.1.50");
}
let settings = crate::settings::Settings::default();
let cfg = ChannelsConfig::resolve(&settings, true).unwrap();
unsafe {
std::env::remove_var("HTTP_PORT");
std::env::remove_var("HTTP_HOST");
}
let http = cfg.http.expect("HttpConfig should be present");
assert_eq!(
http.host, "192.168.1.50",
"explicit host should override tunnel default"
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

These new tests manipulate environment variables, which is a shared global state. This can lead to flaky tests if not handled carefully.

  1. Race Conditions: The tests should acquire a lock on crate::config::helpers::ENV_MUTEX before modifying environment variables to prevent race conditions when tests are run in parallel, as documented in src/config/helpers.rs.
  2. Incomplete Cleanup: The cleanup logic in http_host_defaults_to_loopback_with_tunnel and http_host_defaults_to_all_interfaces_without_tunnel is incomplete. They modify HTTP_HOST but don't clean it up, which can interfere with other tests.

The suggestion below adds the required mutex lock to all three tests and makes the cleanup logic consistent.

    /// When a tunnel is active and HTTP_HOST is not explicitly set, the
    /// webhook server should default to loopback to avoid unnecessary exposure.
    #[test]
    fn http_host_defaults_to_loopback_with_tunnel() {
        let _guard = crate::config::helpers::ENV_MUTEX.lock().unwrap();
        // Set HTTP_PORT to trigger HttpConfig creation, but leave HTTP_HOST unset
        // so the default kicks in.
        unsafe {
            std::env::set_var("HTTP_PORT", "9999");
            std::env::remove_var("HTTP_HOST");
        }
        let settings = crate::settings::Settings::default();
        let cfg = ChannelsConfig::resolve(&settings, true).unwrap();
        unsafe {
            std::env::remove_var("HTTP_PORT");
            std::env::remove_var("HTTP_HOST");
        }
        let http = cfg.http.expect("HttpConfig should be present");
        assert_eq!(
            http.host, "127.0.0.1",
            "tunnel active should default to loopback"
        );
        assert_eq!(http.port, 9999);
    }

    /// Without a tunnel, the webhook server defaults to 0.0.0.0 so external
    /// services can reach it directly.
    #[test]
    fn http_host_defaults_to_all_interfaces_without_tunnel() {
        let _guard = crate::config::helpers::ENV_MUTEX.lock().unwrap();
        unsafe {
            std::env::set_var("HTTP_PORT", "9998");
            std::env::remove_var("HTTP_HOST");
        }
        let settings = crate::settings::Settings::default();
        let cfg = ChannelsConfig::resolve(&settings, false).unwrap();
        unsafe {
            std::env::remove_var("HTTP_PORT");
            std::env::remove_var("HTTP_HOST");
        }
        let http = cfg.http.expect("HttpConfig should be present");
        assert_eq!(
            http.host, "0.0.0.0",
            "no tunnel should default to all interfaces"
        );
    }

    /// An explicit HTTP_HOST always wins regardless of tunnel state.
    #[test]
    fn explicit_http_host_overrides_tunnel_default() {
        let _guard = crate::config::helpers::ENV_MUTEX.lock().unwrap();
        unsafe {
            std::env::set_var("HTTP_PORT", "9997");
            std::env::set_var("HTTP_HOST", "192.168.1.50");
        }
        let settings = crate::settings::Settings::default();
        let cfg = ChannelsConfig::resolve(&settings, true).unwrap();
        unsafe {
            std::env::remove_var("HTTP_PORT");
            std::env::remove_var("HTTP_HOST");
        }
        let http = cfg.http.expect("HttpConfig should be present");
        assert_eq!(
            http.host, "192.168.1.50",
            "explicit host should override tunnel default"
        );
    }
References
  1. Tests that modify shared global state should be serialized using a mutex to prevent race conditions and flakiness when run in parallel.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR reduces webhook server exposure by defaulting HTTP_HOST to loopback (127.0.0.1) when a tunnel is configured, while preserving the existing 0.0.0.0 default when no tunnel is active and keeping HTTP_HOST as an explicit override.

Changes:

  • Resolve tunnel configuration early and pass tunnel_enabled into channel config resolution.
  • Update ChannelsConfig::resolve to default webhook bind host to loopback when tunnel_enabled.
  • Add unit tests covering the new host-defaulting behavior under tunnel/no-tunnel/explicit-host scenarios.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
src/config/mod.rs Resolve tunnel first and pass tunnel.is_enabled() into channel config resolution.
src/config/channels.rs Add tunnel_enabled parameter; change webhook host default based on tunnel state; add tests.
src/cli/doctor.rs Update doctor gateway check to use the new ChannelsConfig::resolve(..., tunnel_enabled) signature.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/config/channels.rs
Comment on lines +411 to +414
unsafe {
std::env::set_var("HTTP_PORT", "9997");
std::env::set_var("HTTP_HOST", "192.168.1.50");
}
Comment thread src/cli/doctor.rs
Comment on lines +408 to +410
let tunnel_enabled = crate::config::TunnelConfig::resolve(settings)
.map(|t| t.is_enabled())
.unwrap_or(false);
Comment thread src/config/channels.rs
Comment on lines +371 to +374
unsafe {
std::env::set_var("HTTP_PORT", "9999");
std::env::remove_var("HTTP_HOST");
}
Comment thread src/config/channels.rs
Comment on lines +392 to +395
unsafe {
std::env::set_var("HTTP_PORT", "9998");
std::env::remove_var("HTTP_HOST");
}
@ilblackdragon ilblackdragon changed the base branch from main to staging March 15, 2026 07:25
zmanian
zmanian previously approved these changes Mar 15, 2026
Copy link
Copy Markdown
Collaborator

@zmanian zmanian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: APPROVE

Clean, well-scoped security hardening fix. When a tunnel (ngrok/cloudflare/tailscale/custom) is configured, the webhook HTTP server now binds to 127.0.0.1 instead of 0.0.0.0. Explicit HTTP_HOST still overrides.

Security analysis — sound:

  • TunnelConfig::is_enabled() covers both static TUNNEL_URL and managed providers
  • Gateway config already defaults to loopback, no gap
  • WASM channel webhooks benefit automatically
  • Edge case: TUNNEL_PROVIDER set but tunnel not yet connected — correctly binds to loopback since user intent is tunnel

Code quality:

  • Minimal diff (3 files), clean resolution ordering in Config::build()
  • Three targeted tests cover: tunnel active, no tunnel, explicit override

One note (non-blocking): Tests use unsafe { std::env::set_var() } without #[serial_test::serial], which could cause flaky parallel test runs. Pre-existing pattern in this file though — not a regression from this PR.

CI all green.

Upstream refactored ChannelsConfig::resolve() to use settings-based
defaults (env > settings > default). Merged our tunnel_enabled param
into the new resolution chain and updated all new upstream test call
sites to pass the tunnel_enabled argument.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ilblackdragon ilblackdragon merged commit 6aaa890 into staging Mar 15, 2026
14 checks passed
@ilblackdragon ilblackdragon deleted the fix/webhook-loopback-with-tunnel branch March 15, 2026 20:38
@ironclaw-ci ironclaw-ci Bot mentioned this pull request Mar 17, 2026
bkutasi pushed a commit to bkutasi/ironclaw that referenced this pull request Mar 28, 2026
…igured (nearai#1194)

When a tunnel provider (ngrok, cloudflare, tailscale, etc.) or static
TUNNEL_URL is configured, external traffic arrives through the tunnel,
so binding 0.0.0.0 is unnecessary attack surface. The webhook server
now defaults to 127.0.0.1 when a tunnel is active. Explicit HTTP_HOST
still overrides the default in all cases.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
drchirag1991 pushed a commit to drchirag1991/ironclaw that referenced this pull request Apr 8, 2026
…igured (nearai#1194)

When a tunnel provider (ngrok, cloudflare, tailscale, etc.) or static
TUNNEL_URL is configured, external traffic arrives through the tunnel,
so binding 0.0.0.0 is unnecessary attack surface. The webhook server
now defaults to 127.0.0.1 when a tunnel is active. Explicit HTTP_HOST
still overrides the default in all cases.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: low Changes to docs, tests, or low-risk modules scope: channel/cli TUI / CLI channel size: M 50-199 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants