Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .circleci/config.yml
Copy link
Copy Markdown
Member

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 ?

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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ rocksdb = "0.21"
sentry = { version = "0.32", features = ["panic", "log"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "time"] }
tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "net", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2.5"
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,21 @@ Callbacks fire on all terminal states:

Callback failures are logged and silently dropped; they never affect the relay flow.

### Callback URL safety (SSRF)

Before a job is stored, `callbackUrl` is validated (`src/utils/callback_security.rs`):

- **HTTPS only** (no `http://` or exotic schemes).
- No **userinfo** (`https://user:pass@…` is rejected).
- Host must not be a **reserved / non-public** IP (private, loopback, link-local, documentation, unspecified, IPv6 ULA, etc.). Domain names are **DNS-resolved**; every resolved address must be allowed.

Outbound webhook HTTP uses **no redirects** and bounded timeouts (`src/utils/callback.rs`).

| Environment variable | Purpose |
|----------------------|---------|
| `RELAYX_CALLBACK_ALLOW_LOOPBACK=true` | Allow `127.0.0.1` / `::1` as callback targets (local development only). |
| `RELAYX_CALLBACK_SKIP_SSRF_CHECKS=true` | **Dangerous:** skip host/IP checks (parse-only). For isolated tests only. |

---

## Error Codes
Expand Down
69 changes: 67 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
use std::{fs, path::PathBuf, sync::OnceLock};
use std::{fmt, fs, path::PathBuf, sync::OnceLock};

use clap::Parser;
use serde::{Deserialize, Serialize};

use crate::types::TokenDetails;

#[derive(Parser, Debug, Clone, Serialize, Deserialize)]
#[derive(Parser, Clone, Serialize, Deserialize)]
#[command(name = "relayx")]
#[command(about = "A modular relayer service with JSON-RPC endpoints")]
pub struct Config {
Expand Down Expand Up @@ -75,6 +75,42 @@ pub struct Config {
pub disable_multichain: bool,
}

impl fmt::Debug for Config {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Config")
.field("rpc_host", &self.rpc_host)
.field("rpc_port", &self.rpc_port)
.field("db_path", &self.db_path)
.field("relayers", &self.relayers)
.field("max_concurrent_requests", &self.max_concurrent_requests)
.field("request_timeout", &self.request_timeout)
.field("config_path", &self.config_path)
.field("http_address", &self.http_address)
.field("http_port", &self.http_port)
.field("http_cors", &self.http_cors)
.field("log_level", &self.log_level)
.field(
"relayer_private_key",
&self
.relayer_private_key
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("<none>"),
)
.field("disable_simulation", &self.disable_simulation)
.field(
"sentry_dsn",
&self
.sentry_dsn
.as_ref()
.map(|_| "<redacted>")
.unwrap_or("<none>"),
)
.field("disable_multichain", &self.disable_multichain)
.finish()
}
}

impl Config {
/// Cached parsed JSON config (loaded once globally)
fn get_json_config(&self) -> Option<&'static serde_json::Value> {
Expand All @@ -99,6 +135,35 @@ impl Config {
Some(value)
}
}

/// Short, non-secret configuration summary for tracing at startup.
pub fn log_summary_for_tracing(&self) -> String {
format!(
"http={}:{} db_path={:?} config_path={:?} disable_simulation={} disable_multichain={} relayer_private_key={} sentry_cli_dsn={} sentry_effective_dsn={}",
self.get_http_address(),
self.get_http_port(),
self.db_path,
self.config_path,
self.disable_simulation,
self.disable_multichain,
if self.relayer_private_key.as_ref().is_some_and(|s| !s.is_empty()) {
"<set>"
} else {
"<none>"
},
if self.sentry_dsn.as_ref().is_some_and(|s| !s.is_empty()) {
"<set>"
} else {
"<none>"
},
if self.get_sentry_dsn().is_some() {
"<set>"
} else {
"<none>"
},
)
}

/// Parse relayers string into a vector of addresses
pub fn get_relayer_addresses(&self) -> Vec<String> {
if self.relayers.is_empty() {
Expand Down
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ async fn main() -> Result<()> {
};

tracing::info!("Starting RelayX service");
tracing::debug!("Configuration: {:?}", config);
tracing::debug!(summary = %config.log_summary_for_tracing(), "loaded configuration");
tracing::info!("Log level set to: {}", filter_str);

// Initialize storage
Expand Down
7 changes: 7 additions & 0 deletions src/methods/send_tx/shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use crate::{
},
utils::{
callback::fire_callback,
callback_security,
errors::rpc_errors::{
insufficient_balance_error, invalid_params_error, quote_expired_error,
simulation_failed_error, transaction_too_large_error, unsupported_capability_error,
Expand Down Expand Up @@ -175,6 +176,12 @@ pub async fn process_single_transaction(
.and_then(|v| v.as_str())
.map(|s| s.to_string());

if let Some(ref u) = callback_url {
callback_security::validate_outbound_webhook_url(u)
.await
.map_err(|_| invalid_params_error())?;
}

let internal_id = Uuid::new_v4();

let relayer_request = RelayerRequest {
Expand Down
24 changes: 18 additions & 6 deletions src/utils/callback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,25 @@
//! (max 100 items per request in that spec; we send one item per callback) of flattened
//! status objects using `chainIndex`, `requestId`, `txHash`, etc.

use std::sync::OnceLock;

use serde::Serialize;
use uuid::Uuid;

use crate::{config::Config, RelayerRequest, SpecStatusResponse};

fn webhook_http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.expect("reqwest webhook client builder")
})
}

/// Convert a decimal quantity string (e.g. block number from receipts) to a `0x` hex string
/// as required by the OKX transaction-status webhook for `blockHeight` / `gasUsed`.
fn decimal_string_to_hex_quantity(s: &str) -> Option<String> {
Expand Down Expand Up @@ -113,6 +127,9 @@ pub fn build_okx_transaction_status_item(
/// POST the status update to the callback URL as a **JSON array** of
/// [`OkxTransactionStatusItem`] (OKX “Submit Intent Status” wire format).
///
/// Uses a shared HTTP client: **no redirects** (mitigates SSRF redirect chains; issue #30),
/// 30s total / 10s connect timeout.
///
/// Failures are logged and silently swallowed — a failed callback never affects the relay flow.
pub async fn fire_callback(req: &RelayerRequest, status: &SpecStatusResponse, cfg: &Config) {
let url = match &req.callback_url {
Expand All @@ -123,12 +140,7 @@ pub async fn fire_callback(req: &RelayerRequest, status: &SpecStatusResponse, cf
let item = build_okx_transaction_status_item(req, status, cfg);
let payload = vec![item];

match reqwest::Client::new()
.post(&url)
.json(&payload)
.send()
.await
{
match webhook_http_client().post(&url).json(&payload).send().await {
Ok(resp) => {
tracing::info!(
"Callback delivered for task_id {} → {} (HTTP {})",
Expand Down
154 changes: 154 additions & 0 deletions src/utils/callback_security.rs
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"));
}
}
1 change: 1 addition & 0 deletions src/utils/mod.rs
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;
Expand Down
Loading