-
Notifications
You must be signed in to change notification settings - Fork 0
fix(security): address GitHub issues #30 and #31 (webhook SSRF, config logging) #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ch4r10t33r
merged 3 commits into
master
from
fix/issue-30-31-callback-ssrf-config-redact
May 12, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
aa967a3
fix(security): webhook SSRF guard and redact secrets in Config logs
ch4r10t33r e18d4e9
fix(ci): satisfy clippy on callback env flags; add minimal CircleCI c…
ch4r10t33r 1e773f8
Merge origin/master into fix/issue-30-31-callback-ssrf-config-redact
ch4r10t33r File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # Legacy CircleCI project hook: relayx CI runs on GitHub Actions (.github/workflows/). | ||
| # This minimal pipeline keeps the CircleCI app from failing with "no configuration found" | ||
| # until the project is disabled in the CircleCI UI. | ||
| version: 2.1 | ||
|
|
||
| jobs: | ||
| noop: | ||
| docker: | ||
| - image: cimg/base:2024.02 | ||
| resource_class: small | ||
| steps: | ||
| - checkout | ||
| - run: | ||
| name: Skip (use GitHub Actions) | ||
| command: echo "Primary CI is GitHub Actions; see .github/workflows/" | ||
|
|
||
| workflows: | ||
| default: | ||
| jobs: | ||
| - noop |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| //! Guardrails for outbound status webhooks (`context.callbackUrl`). | ||
| //! | ||
| //! Mitigates SSRF (issue #30): restrict schemes, forbid URL credentials, block | ||
| //! non-public/reserved destinations by default, and resolve hostnames to ensure no | ||
| //! resolved address is disallowed. | ||
|
|
||
| use std::net::IpAddr; | ||
|
|
||
| use url::Url; | ||
|
|
||
| fn env_truthy(name: &str) -> bool { | ||
| std::env::var(name) | ||
| .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) | ||
| .unwrap_or(false) | ||
| } | ||
|
|
||
| fn ssrf_checks_disabled() -> bool { | ||
| env_truthy("RELAYX_CALLBACK_SKIP_SSRF_CHECKS") | ||
| } | ||
|
|
||
| fn allow_loopback_callback_targets() -> bool { | ||
| env_truthy("RELAYX_CALLBACK_ALLOW_LOOPBACK") | ||
| } | ||
|
|
||
| /// True when this IP must not be used as a webhook target (strict default). | ||
| fn is_blocked_ip(ip: IpAddr) -> bool { | ||
| if allow_loopback_callback_targets() && ip.is_loopback() { | ||
| return false; | ||
| } | ||
| match ip { | ||
| IpAddr::V4(v) => { | ||
| v.is_private() | ||
| || v.is_loopback() | ||
| || v.is_link_local() | ||
| || v.is_broadcast() | ||
| || v.is_documentation() | ||
| || v.is_unspecified() | ||
| } | ||
| IpAddr::V6(v) => { | ||
| v.is_loopback() | ||
| || v.is_unique_local() | ||
| || v.is_unicast_link_local() | ||
| || v.is_multicast() | ||
| || v.is_unspecified() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Validate a client-supplied webhook URL before persisting the relay job. | ||
| /// | ||
| /// Policy (unless `RELAYX_CALLBACK_SKIP_SSRF_CHECKS` is set): | ||
| /// - Only `https` URLs (no `http`, `file`, `gopher`, etc.). | ||
| /// - No username/password embedded in the URL. | ||
| /// - Literal IP hosts must not be loopback, private, link-local, documentation, etc. | ||
| /// - Domain hosts are resolved with [`tokio::net::lookup_host`]; every resolved address | ||
| /// must pass the same IP rules. | ||
| /// | ||
| /// Set `RELAYX_CALLBACK_ALLOW_LOOPBACK=true` to permit loopback targets (local dev only). | ||
| pub async fn validate_outbound_webhook_url(raw: &str) -> Result<(), String> { | ||
| if raw.len() > 2048 { | ||
| return Err("callback URL exceeds maximum length".into()); | ||
| } | ||
|
|
||
| if ssrf_checks_disabled() { | ||
| Url::parse(raw).map_err(|e| format!("invalid URL: {e}"))?; | ||
| return Ok(()); | ||
| } | ||
|
|
||
| let url = Url::parse(raw).map_err(|e| format!("invalid URL: {e}"))?; | ||
|
|
||
| if !url.username().is_empty() || url.password().is_some() { | ||
| return Err("callback URL must not contain credentials".into()); | ||
| } | ||
|
|
||
| if url.scheme() != "https" { | ||
| return Err("only https callback URLs are allowed".into()); | ||
| } | ||
|
|
||
| let host = url.host_str().ok_or("callback URL is missing a host")?; | ||
| let port = url.port_or_known_default().unwrap_or(443); | ||
|
|
||
| match url.host() { | ||
| Some(url::Host::Ipv4(ip)) => { | ||
| if is_blocked_ip(IpAddr::V4(ip)) { | ||
| return Err("callback host IP is not an allowed public address".into()); | ||
| } | ||
| } | ||
| Some(url::Host::Ipv6(ip)) => { | ||
| if is_blocked_ip(IpAddr::V6(ip)) { | ||
| return Err("callback host IP is not an allowed public address".into()); | ||
| } | ||
| } | ||
| Some(url::Host::Domain(_)) => { | ||
| let mut found = false; | ||
| for sa in tokio::net::lookup_host((host, port)) | ||
| .await | ||
| .map_err(|e| format!("DNS lookup failed for callback host: {e}"))? | ||
| { | ||
| found = true; | ||
| if is_blocked_ip(sa.ip()) { | ||
| return Err(format!( | ||
| "callback host resolves to a disallowed address ({})", | ||
| sa.ip() | ||
| )); | ||
| } | ||
| } | ||
| if !found { | ||
| return Err("callback host resolved to no addresses".into()); | ||
| } | ||
| } | ||
| None => return Err("callback URL has an invalid host".into()), | ||
| } | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn blocked_ipv4_detection() { | ||
| assert!(is_blocked_ip(IpAddr::V4("127.0.0.1".parse().unwrap()))); | ||
| assert!(is_blocked_ip(IpAddr::V4("10.0.0.1".parse().unwrap()))); | ||
| assert!(is_blocked_ip(IpAddr::V4( | ||
| "169.254.169.254".parse().unwrap() | ||
| ))); | ||
| assert!(!is_blocked_ip(IpAddr::V4("8.8.8.8".parse().unwrap()))); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn rejects_https_with_literal_private_ip() { | ||
| let err = validate_outbound_webhook_url("https://10.0.0.1/webhook") | ||
| .await | ||
| .unwrap_err(); | ||
| assert!(err.contains("not an allowed public")); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn rejects_non_https_scheme() { | ||
| let err = validate_outbound_webhook_url("http://8.8.8.8/webhook") | ||
| .await | ||
| .unwrap_err(); | ||
| assert!(err.contains("only https")); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn rejects_credentials_in_userinfo() { | ||
| let err = validate_outbound_webhook_url("https://user:[email protected]/hook") | ||
| .await | ||
| .unwrap_err(); | ||
| assert!(err.contains("credentials")); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| pub mod callback; | ||
| pub mod callback_security; | ||
| pub mod errors; | ||
| pub mod hex; | ||
| pub mod misc; | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this only a fallback until CI is connected to circle ?