Skip to content

fix(setup): validate channel credentials during setup#684

Open
Protocol-zero-0 wants to merge 1 commit intonearai:mainfrom
Protocol-zero-0:fix/651-channel-credential-validation
Open

fix(setup): validate channel credentials during setup#684
Protocol-zero-0 wants to merge 1 commit intonearai:mainfrom
Protocol-zero-0:fix/651-channel-credential-validation

Conversation

@Protocol-zero-0
Copy link

Summary

  • implement setup-time credential validation for WASM channels that declare a validation_endpoint
  • substitute saved secrets into the validation URL, enforce public HTTPS targets only, and issue a short validation request
  • warn on validation failures without blocking setup, and add focused regression tests for placeholder substitution and URL safety checks

Scope

This PR intentionally covers only the first half of issue #651: channel credential validation during setup.

It does not implement the routine webhook trigger endpoint from the same issue, to keep the change small and reviewer-friendly.

Why

Right now the setup wizard accepts channel secrets blindly, so users often only discover a bad token after the channel fails to start. This change moves that feedback into setup while still keeping the flow forgiving.

Behavior

  • if a channel declares validation_endpoint, setup now validates credentials immediately after saving them
  • validation endpoint placeholders like {telegram_bot_token} are replaced from the secrets store
  • final URLs must be public https targets; localhost, private IPs, and hostnames resolving to private IPs are rejected
  • validation failures show a warning and setup continues

Tests

  • cargo test --lib test_substitute_validation_placeholders
  • target/debug/deps/ironclaw-ec6ff0546cc1c6a3 --exact setup::channels::tests::test_validate_public_https_url_rejects_localhost
  • target/debug/deps/ironclaw-ec6ff0546cc1c6a3 --exact setup::channels::tests::test_validate_public_https_url_rejects_private_ip
  • target/debug/deps/ironclaw-ec6ff0546cc1c6a3 --exact setup::channels::tests::test_validate_public_https_url_rejects_http
  • target/debug/deps/ironclaw-ec6ff0546cc1c6a3 --exact setup::channels::tests::test_validate_public_https_url_accepts_public_https_literal_ip
  • cargo fmt --check
  • cargo clippy --lib --tests -- -D warnings

Notes

I used a constrained clippy run (lib + tests) instead of a fresh full all-features rebuild because disk pressure on this machine makes repeated full rebuilds unreliable, but the paths touched here are covered by the targeted test and lint runs above.

Made with Cursor

Validate channel setup credentials against the declared validation endpoint so users get immediate feedback before startup failures. Substitute stored secrets into the validation URL, block private or local targets, and warn on failed checks without interrupting setup.

Made-with: Cursor
Copilot AI review requested due to automatic review settings March 7, 2026 21:54
@github-actions github-actions bot added scope: setup Onboarding / setup size: L 200-499 changed lines labels Mar 7, 2026
@gemini-code-assist
Copy link
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 enhances the channel setup process by introducing immediate credential validation for WASM channels. Previously, users would only discover invalid credentials after a channel failed to start. By validating credentials during setup, the system now provides early feedback, improving the user experience and reducing potential operational issues, while still allowing setup to proceed with a warning if validation fails.

Highlights

  • Credential Validation: Implemented setup-time credential validation for WASM channels that specify a validation_endpoint.
  • Secret Substitution: Enabled substitution of saved secrets into the validation URL, allowing dynamic endpoint construction.
  • URL Safety Checks: Enforced public HTTPS targets only, rejecting localhost, private IPs, and hostnames resolving to private IPs for validation endpoints.
  • Non-blocking Setup: Configured validation failures to issue a warning, allowing setup to continue without blocking the user.
  • Regression Tests: Added focused regression tests covering placeholder substitution and URL safety checks.
Changelog
  • src/setup/channels.rs
    • Modified setup_wasm_channel to incorporate a call to the new credential validation logic when a validation_endpoint is present.
    • Added validate_channel_credentials function to orchestrate the validation process, including secret substitution, URL validation, and an HTTP GET request.
    • Implemented substitute_validation_placeholders to replace secret placeholders in validation URLs using a regular expression.
    • Introduced validate_public_https_url to parse and enforce security constraints on validation URLs, ensuring they are HTTPS and do not target private or local networks.
    • Added is_disallowed_ip helper function to identify various types of private, loopback, and other restricted IP addresses.
    • Expanded test suite with new test_secrets_context helper and tests for substitute_validation_placeholders and validate_public_https_url covering various success and failure scenarios.
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.

@github-actions github-actions bot added risk: high Safety, secrets, auth, or critical infrastructure contributor: new First-time contributor labels Mar 7, 2026
Copy link
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 valuable feature by adding credential validation at setup time for WASM channels, which will improve the user experience by providing earlier feedback. The implementation is well-structured with new functions for placeholder substitution and URL validation, accompanied by good test coverage. However, critical security vulnerabilities have been identified. These include a Time-of-check-to-time-of-use (TOCTOU) race condition (also known as a DNS rebinding vulnerability) and the potential for SSRF protection bypass using IPv4-mapped IPv6 addresses, both of which could allow an attacker to bypass SSRF protections. Furthermore, sensitive secrets are leaked in error messages printed to the console. These issues should be addressed to ensure the validation process does not compromise the security of the user's credentials or the host system.

Comment on lines +877 to +931
fn validate_public_https_url(url: &str) -> Result<Url, ChannelSetupError> {
use std::net::{IpAddr, ToSocketAddrs};

let parsed = Url::parse(url)
.map_err(|e| ChannelSetupError::Validation(format!("Invalid URL: {}", e)))?;

if parsed.scheme() != "https" {
return Err(ChannelSetupError::Validation(
"Validation endpoint must use https".to_string(),
));
}

if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(ChannelSetupError::Validation(
"Validation endpoint cannot contain userinfo".to_string(),
));
}

let host = parsed
.host_str()
.ok_or_else(|| ChannelSetupError::Validation("Validation URL missing host".to_string()))?;
let host_lower = host.to_lowercase();

if host_lower == "localhost" || host_lower.ends_with(".localhost") {
return Err(ChannelSetupError::Validation(
"Validation endpoint cannot target localhost".to_string(),
));
}

if let Ok(ip) = host.parse::<IpAddr>() {
if is_disallowed_ip(&ip) {
return Err(ChannelSetupError::Validation(format!(
"Validation endpoint cannot target private or local IP {}",
ip
)));
}
return Ok(parsed);
}

let port = parsed.port_or_known_default().unwrap_or(443);
let socket_addr = format!("{}:{}", host, port);
if let Ok(addrs) = socket_addr.to_socket_addrs() {
for addr in addrs {
if is_disallowed_ip(&addr.ip()) {
return Err(ChannelSetupError::Validation(format!(
"Validation hostname '{}' resolves to disallowed IP {}",
host,
addr.ip()
)));
}
}
}

Ok(parsed)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

security-critical critical

This implementation has a Time-of-check-to-time-of-use (TOCTOU) vulnerability, also known as a DNS rebinding attack. The function resolves a hostname to IP addresses for validation but then discards them. The caller, validate_channel_credentials, receives only the URL and performs a new DNS lookup for the actual request. An attacker could return a safe IP during validation and a private IP for the request, bypassing the SSRF protection.

Additionally, if DNS resolution fails (to_socket_addrs() returns an Err), the error is ignored, and the function incorrectly returns Ok, bypassing the check entirely.

To fix this, you should:

  1. Return the validated SocketAddrs from this function.
  2. Handle DNS resolution errors.
  3. In validate_channel_credentials, use reqwest::ClientBuilder::resolve to pin the request to the validated IP addresses.

Here is a suggested implementation for validate_public_https_url. You will also need to update validate_channel_credentials to handle the new return type and configure the reqwest::Client.

Example for validate_channel_credentials:

let (parsed, resolved_addrs) = validate_public_https_url(&validation_url)?;
let mut client_builder = reqwest::Client::builder()
    .timeout(std::time::Duration::from_secs(5));

if let Some(host) = parsed.host_str() {
    for addr in resolved_addrs {
        client_builder = client_builder.resolve(host, addr);
    }
}

let client = client_builder.build()?;
// ... make request with client
fn validate_public_https_url(
    url: &str,
) -> Result<(Url, Vec<std::net::SocketAddr>), ChannelSetupError> {
    use std::net::{IpAddr, SocketAddr, ToSocketAddrs};

    let parsed =
        Url::parse(url).map_err(|e| ChannelSetupError::Validation(format!("Invalid URL: {}", e)))?;

    if parsed.scheme() != "https" {
        return Err(ChannelSetupError::Validation(
            "Validation endpoint must use https".to_string(),
        ));
    }

    if !parsed.username().is_empty() || parsed.password().is_some() {
        return Err(ChannelSetupError::Validation(
            "Validation endpoint cannot contain userinfo".to_string(),
        ));
    }

    let host = parsed
        .host_str()
        .ok_or_else(|| ChannelSetupError::Validation("Validation URL missing host".to_string()))?;
    let host_lower = host.to_lowercase();

    if host_lower == "localhost" || host_lower.ends_with(".localhost") {
        return Err(ChannelSetupError::Validation(
            "Validation endpoint cannot target localhost".to_string(),
        ));
    }

    let port = parsed.port_or_known_default().unwrap_or(443);

    if let Ok(ip) = host.parse::<IpAddr>() {
        if is_disallowed_ip(&ip) {
            return Err(ChannelSetupError::Validation(format!(
                "Validation endpoint cannot target private or local IP {}",
                ip
            )));
        }
        let addr = SocketAddr::new(ip, port);
        return Ok((parsed, vec![addr]));
    }

    let socket_addr = format!("{}:{}", host, port);
    let addrs: Vec<SocketAddr> = socket_addr
        .to_socket_addrs()
        .map_err(|e| {
            ChannelSetupError::Validation(format!("DNS resolution failed for {}: {}", host, e))
        })?
        .collect();

    if addrs.is_empty() {
        return Err(ChannelSetupError::Validation(format!(
            "Validation hostname '{}' did not resolve to any IP addresses",
            host
        )));
    }

    for addr in &addrs {
        if is_disallowed_ip(&addr.ip()) {
            return Err(ChannelSetupError::Validation(format!(
                "Validation hostname '{}' resolves to disallowed IP {}",
                host,
                addr.ip()
            )));
        }
    }

    Ok((parsed, addrs))
}
References
  1. To prevent DNS-based SSRF vulnerabilities, resolve hostnames to IP addresses, validate all resolved IPs against restricted ranges, and pin the validated addresses for the connection to prevent TOCTOU race conditions.

Comment on lines +846 to +850
Err(ChannelSetupError::Validation(format!(
"Validation endpoint returned HTTP {} for {}",
response.status(),
parsed
)))
Copy link
Contributor

Choose a reason for hiding this comment

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

security-high high

The error message constructed here includes the full parsed URL, which contains sensitive secrets substituted from the secrets store (e.g., API tokens). If the validation request fails, this error message is returned and subsequently printed to the console on line 811. This leads to the exposure of sensitive credentials in terminal output or log files.

To remediate this, avoid including the full URL in the error message. Instead, report only the host or a redacted version of the URL where placeholders are replaced with a string like [REDACTED].

Comment on lines +933 to +952
fn is_disallowed_ip(ip: &std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_unspecified()
|| *v4 == std::net::Ipv4Addr::new(169, 254, 169, 254)
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64)
}
std::net::IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unique_local()
|| v6.is_unicast_link_local()
|| v6.is_multicast()
|| v6.is_unspecified()
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

security-medium medium

The is_disallowed_ip function does not account for IPv4-mapped IPv6 addresses (e.g., ::ffff:127.0.0.1). An attacker could provide a validation endpoint using such an address to bypass the SSRF protection and target internal services or the loopback interface.

To fix this, you should check if the IPv6 address is an IPv4-mapped address and, if so, validate the embedded IPv4 address against the disallowed list. In Rust, you can use Ipv6Addr::to_ipv4_mapped() (if on nightly) or check if the first 12 bytes are 00 00 00 00 00 00 00 00 00 00 FF FF.

Copy link
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

Adds setup-time credential validation for WASM channels by substituting stored secrets into a schema-provided validation_endpoint, validating the final URL for SSRF safety, and issuing a short HTTPS request to provide early feedback during the setup wizard.

Changes:

  • Perform non-blocking (warning-only) credential validation after saving required secrets when validation_endpoint is present.
  • Implement placeholder substitution ({secret_name}) from the secrets store and URL safety checks intended to reject localhost/private targets.
  • Add regression tests for placeholder substitution and basic URL safety rules (https/localhost/private IPv4).

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

Comment on lines +918 to +929
if let Ok(addrs) = socket_addr.to_socket_addrs() {
for addr in addrs {
if is_disallowed_ip(&addr.ip()) {
return Err(ChannelSetupError::Validation(format!(
"Validation hostname '{}' resolves to disallowed IP {}",
host,
addr.ip()
)));
}
}
}

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

validate_public_https_url uses ToSocketAddrs (blocking DNS resolution) inside an async setup flow, and it currently fails open when resolution errors (or returns 0 addrs), allowing the subsequent reqwest request to resolve/fetch without having enforced the “public only” constraint. Consider switching to tokio::net::lookup_host, erroring on resolution failure/empty results, and (ideally) pinning the resolved addrs via ClientBuilder::resolve_to_addrs to avoid re-resolution.

Suggested change
if let Ok(addrs) = socket_addr.to_socket_addrs() {
for addr in addrs {
if is_disallowed_ip(&addr.ip()) {
return Err(ChannelSetupError::Validation(format!(
"Validation hostname '{}' resolves to disallowed IP {}",
host,
addr.ip()
)));
}
}
}
let addrs = socket_addr
.to_socket_addrs()
.map_err(|e| {
ChannelSetupError::Validation(format!(
"Validation hostname '{}' could not be resolved: {}",
host, e
))
})?;
let mut saw_addr = bool::false();
for addr in addrs {
saw_addr = true;
if is_disallowed_ip(&addr.ip()) {
return Err(ChannelSetupError::Validation(format!(
"Validation hostname '{}' resolves to disallowed IP {}",
host,
addr.ip()
)));
}
}
if !saw_addr {
return Err(ChannelSetupError::Validation(format!(
"Validation hostname '{}' did not resolve to any addresses",
host
)));
}

Copilot uses AI. Check for mistakes.
Comment on lines +945 to +949
v6.is_loopback()
|| v6.is_unique_local()
|| v6.is_unicast_link_local()
|| v6.is_multicast()
|| v6.is_unspecified()
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

IPv4-mapped IPv6 addresses (e.g. https://[::ffff:192.168.1.10]/) will parse as IPv6 and currently bypass the V4 private-range checks because the V6 branch doesn’t normalize to_ipv4_mapped(). This is a known SSRF bypass pattern; consider normalizing mapped addresses before evaluating disallowed ranges (see similar handling in src/tools/builtin/skill_tools.rs).

Suggested change
v6.is_loopback()
|| v6.is_unique_local()
|| v6.is_unicast_link_local()
|| v6.is_multicast()
|| v6.is_unspecified()
if let Some(v4) = v6.to_ipv4_mapped() {
// Treat IPv4-mapped IPv6 addresses the same as IPv4
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_unspecified()
|| v4 == std::net::Ipv4Addr::new(169, 254, 169, 254)
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64)
} else {
v6.is_loopback()
|| v6.is_unique_local()
|| v6.is_unicast_link_local()
|| v6.is_multicast()
|| v6.is_unspecified()
}

Copilot uses AI. Check for mistakes.
Comment on lines +868 to +872
for secret_name in placeholder_names {
let secret_value = secrets.get_secret(&secret_name).await?;
let placeholder = format!("{{{}}}", secret_name);
resolved = resolved.replace(&placeholder, secret_value.expose_secret());
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

Placeholder substitution inserts raw secret values into the URL without percent-encoding. If a token contains reserved characters (?, #, &, /, %, etc.), the resulting URL may be invalid or change semantics (query injection). Consider parsing the URL and percent-encoding substituted values (or constraining placeholders to query values and encoding accordingly).

Copilot uses AI. Check for mistakes.
Comment on lines +1162 to +1190
#[test]
fn test_validate_public_https_url_rejects_localhost() {
let err = validate_public_https_url("https://localhost/api")
.unwrap_err()
.to_string();
assert!(err.contains("localhost"));
}

#[test]
fn test_validate_public_https_url_rejects_private_ip() {
let err = validate_public_https_url("https://192.168.1.10/api")
.unwrap_err()
.to_string();
assert!(err.contains("private or local IP"));
}

#[test]
fn test_validate_public_https_url_rejects_http() {
let err = validate_public_https_url("http://example.com/api")
.unwrap_err()
.to_string();
assert!(err.contains("must use https"));
}

#[test]
fn test_validate_public_https_url_accepts_public_https_literal_ip() {
let parsed = validate_public_https_url("https://8.8.8.8/api").unwrap();
assert_eq!(parsed.as_str(), "https://8.8.8.8/api");
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

Tests cover localhost and private IPv4 literals, but don’t cover common SSRF bypasses this validator is intended to prevent (e.g. localhost. with a trailing dot, and IPv4-mapped IPv6 like https://[::ffff:192.168.1.10]/). Adding focused regression tests for those cases would help lock down the URL safety checks.

Copilot uses AI. Check for mistakes.
Comment on lines +847 to +849
"Validation endpoint returned HTTP {} for {}",
response.status(),
parsed
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

The validation error includes the fully substituted URL (parsed) in the message. Since placeholders are replaced with secrets, this can leak credentials to the console/logs via the warning printed during setup. Consider redacting sensitive parts (e.g., strip query/fragment, or avoid printing the URL entirely) in validation/network errors.

Suggested change
"Validation endpoint returned HTTP {} for {}",
response.status(),
parsed
"Validation endpoint returned HTTP {}",
response.status(),

Copilot uses AI. Check for mistakes.
Comment on lines +832 to +835
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| ChannelSetupError::Network(format!("Failed to build HTTP client: {}", e)))?;
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

reqwest::Client follows redirects by default, so a public HTTPS validation endpoint could redirect to http:// or to a private/localhost target and still be fetched. For SSRF safety, disable redirects (Policy::none()) or enforce a custom redirect policy that re-validates each hop (scheme + host/IP).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: new First-time contributor risk: high Safety, secrets, auth, or critical infrastructure scope: setup Onboarding / setup size: L 200-499 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants