From c84a1dc73bce613582e6fcb1d735c244992f9c05 Mon Sep 17 00:00:00 2001 From: Trevor Porter Date: Thu, 20 Jun 2024 17:28:26 +0100 Subject: [PATCH] feat: address blacklisting in the relayer (#4000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description See context here https://discord.com/channels/935678348330434570/1236041564673806427/1252918894755319920 and https://discord.com/channels/935678348330434570/1252953507833708605/1252954456400592997 This is a rudimentary first step toward blacklisting addresses. Future options are outlined in https://discord.com/channels/935678348330434570/1236041564673806427/1252918894755319920. Eventually we can also fetch these addresses directly within the agent from an updated list that we cache and re-fetch occasionally. For now, this just relies on configured addresses. The approach is: - Allow hex addresses to be configured. These are intentionally treated as a Vec instead of an H160 / H256. This is because treating them as H160 is a leak of Ethereum-specific logic into non-chain-specific code in the relayer, and treating them as H256 would pad Ethereum addresses with a bunch of zeroes which would diminish the accuracy of our heuristic implemented in this. The subsequence stuff is a bit unfortunate because it's O(n^2) but 🤷‍♂️ - The heuristic is as follows - if any of the configured addresses are found as a subsequence in the message sender, recipient, or body, it's thrown out by the message processor, and the message will never make its way to the op_submitter. This heuristic for the body is to prevent warp route transfers that include that address. We could maybe convert Vec to H256 for the sender and recipient, I'm open to this - I just went with the substring approach bc it felt easiest tbh. ### Drive-by changes ### Related issues - Fixes https://github.com/hyperlane-xyz/issues/issues/1285 ### Backward compatibility ### Testing --- rust/agents/relayer/src/msg/blacklist.rs | 123 ++++++++++++++++++ rust/agents/relayer/src/msg/mod.rs | 1 + rust/agents/relayer/src/msg/processor.rs | 46 +++++-- rust/agents/relayer/src/relayer.rs | 33 +++-- rust/agents/relayer/src/settings/mod.rs | 64 +++++++++ .../cursors/sequence_aware/forward.rs | 8 +- .../config/environments/mainnet3/agent.ts | 2 +- typescript/infra/src/config/agent/relayer.ts | 53 +++++++- typescript/sdk/src/metadata/agentConfig.ts | 4 + 9 files changed, 302 insertions(+), 32 deletions(-) create mode 100644 rust/agents/relayer/src/msg/blacklist.rs diff --git a/rust/agents/relayer/src/msg/blacklist.rs b/rust/agents/relayer/src/msg/blacklist.rs new file mode 100644 index 000000000..1e9202086 --- /dev/null +++ b/rust/agents/relayer/src/msg/blacklist.rs @@ -0,0 +1,123 @@ +use hyperlane_core::HyperlaneMessage; + +#[derive(Debug, Clone, Default)] +pub struct AddressBlacklist { + // A list of addresses that are blocked from being relayed. + // Addresses are any length to support different address types. + pub blacklist: Vec>, +} + +impl AddressBlacklist { + pub fn new(blacklist: Vec>) -> Self { + Self { blacklist } + } + + /// Returns true if the message is blocked by the blacklist. + /// At the moment, this only checks if the sender, recipient, or body of the + /// message contains any of the blocked addresses. + pub fn find_blacklisted_address(&self, message: &HyperlaneMessage) -> Option> { + self.blacklist.iter().find_map(|address| { + if is_subsequence(message.sender.as_bytes(), address) + || is_subsequence(message.recipient.as_bytes(), address) + || is_subsequence(&message.body, address) + { + // Return the blocked address that was found. + Some(address.clone()) + } else { + None + } + }) + } +} + +/// Returns true if `needle` is a subsequence of `haystack`. +fn is_subsequence(mut haystack: &[T], needle: &[T]) -> bool { + if needle.is_empty() { + return true; + } + + while !haystack.is_empty() { + if needle.len() > haystack.len() { + return false; + } + if haystack.starts_with(needle) { + return true; + } + haystack = &haystack[1..]; + } + false +} + +#[cfg(test)] +mod test { + use hyperlane_core::H256; + + use super::*; + + #[test] + fn test_is_subsequence() { + assert!(is_subsequence(b"hello", b"hello")); + assert!(is_subsequence(b"hello", b"he")); + assert!(is_subsequence(b"hello", b"lo")); + assert!(is_subsequence(b"hello", b"")); + assert!(is_subsequence(b"hello", b"o")); + + assert!(!is_subsequence(b"hello", b"hello world")); + assert!(!is_subsequence(b"hello", b"world")); + assert!(!is_subsequence(b"hello", b"world hello")); + } + + #[test] + fn test_is_blocked() { + let blocked = b"blocked"; + let blocklist = AddressBlacklist::new(vec![blocked.to_vec()]); + + let bytes_with_subsequence = |subsequence: &[u8], index: usize, len: usize| { + let mut bytes = vec![0; len]; + bytes[index..index + subsequence.len()].copy_from_slice(subsequence); + bytes + }; + + let h256_with_subsequence = |subsequence: &[u8], index: usize| { + let bytes = bytes_with_subsequence(subsequence, index, H256::len_bytes()); + H256::from_slice(&bytes) + }; + + // Blocked - sender includes the blocked address + let message = HyperlaneMessage { + sender: h256_with_subsequence(blocked, 0), + ..Default::default() + }; + assert_eq!( + blocklist.find_blacklisted_address(&message), + Some(blocked.to_vec()) + ); + + // Blocked - recipient includes the blocked address + let message = HyperlaneMessage { + recipient: h256_with_subsequence(blocked, 20), + ..Default::default() + }; + assert_eq!( + blocklist.find_blacklisted_address(&message), + Some(blocked.to_vec()) + ); + + // Blocked - body includes the blocked address + let message = HyperlaneMessage { + body: bytes_with_subsequence(blocked, 100 - blocked.len(), 100), + ..Default::default() + }; + assert_eq!( + blocklist.find_blacklisted_address(&message), + Some(blocked.to_vec()) + ); + + // Not blocked - sender, recipient, and body do not include the blocked address + let message = HyperlaneMessage { + body: vec![1; 100], + ..Default::default() + }; + assert!(blocklist.find_blacklisted_address(&message).is_none()); + } +} diff --git a/rust/agents/relayer/src/msg/mod.rs b/rust/agents/relayer/src/msg/mod.rs index dd7bac22b..abf00ec05 100644 --- a/rust/agents/relayer/src/msg/mod.rs +++ b/rust/agents/relayer/src/msg/mod.rs @@ -25,6 +25,7 @@ //! - FallbackProviderSubmitter (Serialized, but if some RPC provider sucks, //! switch everyone to new one) +pub(crate) mod blacklist; pub(crate) mod gas_payment; pub(crate) mod metadata; pub(crate) mod op_queue; diff --git a/rust/agents/relayer/src/msg/processor.rs b/rust/agents/relayer/src/msg/processor.rs index 664c1dc64..40808ad3d 100644 --- a/rust/agents/relayer/src/msg/processor.rs +++ b/rust/agents/relayer/src/msg/processor.rs @@ -8,6 +8,7 @@ use std::{ use async_trait::async_trait; use derive_new::new; +use ethers::utils::hex; use eyre::Result; use hyperlane_base::{ db::{HyperlaneRocksDB, ProcessMessage}, @@ -18,15 +19,19 @@ use prometheus::IntGauge; use tokio::sync::mpsc::UnboundedSender; use tracing::{debug, instrument, trace}; -use super::{metadata::AppContextClassifier, pending_message::*}; +use super::{blacklist::AddressBlacklist, metadata::AppContextClassifier, pending_message::*}; use crate::{processor::ProcessorExt, settings::matching_list::MatchingList}; /// Finds unprocessed messages from an origin and submits then through a channel /// for to the appropriate destination. #[allow(clippy::too_many_arguments)] pub struct MessageProcessor { - whitelist: Arc, - blacklist: Arc, + /// A matching list of messages that should be whitelisted. + message_whitelist: Arc, + /// A matching list of messages that should be blacklisted. + message_blacklist: Arc, + /// Addresses that messages may not interact with. + address_blacklist: Arc, metrics: MessageProcessorMetrics, /// channel for each destination chain to send operations (i.e. message /// submissions) to @@ -217,8 +222,8 @@ impl Debug for MessageProcessor { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!( f, - "MessageProcessor {{ whitelist: {:?}, blacklist: {:?}, nonce_iterator: {:?}}}", - self.whitelist, self.blacklist, self.nonce_iterator + "MessageProcessor {{ message_whitelist: {:?}, message_blacklist: {:?}, address_blacklist: {:?}, nonce_iterator: {:?}}}", + self.message_whitelist, self.message_blacklist, self.address_blacklist, self.nonce_iterator ) } } @@ -247,14 +252,25 @@ impl ProcessorExt for MessageProcessor { let destination = msg.destination; // Skip if not whitelisted. - if !self.whitelist.msg_matches(&msg, true) { - debug!(?msg, whitelist=?self.whitelist, "Message not whitelisted, skipping"); + if !self.message_whitelist.msg_matches(&msg, true) { + debug!(?msg, whitelist=?self.message_whitelist, "Message not whitelisted, skipping"); return Ok(()); } // Skip if the message is blacklisted - if self.blacklist.msg_matches(&msg, false) { - debug!(?msg, blacklist=?self.blacklist, "Message blacklisted, skipping"); + if self.message_whitelist.msg_matches(&msg, false) { + debug!(?msg, blacklist=?self.message_whitelist, "Message blacklisted, skipping"); + return Ok(()); + } + + // Skip if the message involves a blacklisted address + if let Some(blacklisted_address) = self.address_blacklist.find_blacklisted_address(&msg) + { + debug!( + ?msg, + blacklisted_address = hex::encode(blacklisted_address), + "Message involves blacklisted address, skipping" + ); return Ok(()); } @@ -291,18 +307,21 @@ impl ProcessorExt for MessageProcessor { } impl MessageProcessor { + #[allow(clippy::too_many_arguments)] pub fn new( db: HyperlaneRocksDB, - whitelist: Arc, - blacklist: Arc, + message_whitelist: Arc, + message_blacklist: Arc, + address_blacklist: Arc, metrics: MessageProcessorMetrics, send_channels: HashMap>, destination_ctxs: HashMap>, metric_app_contexts: Vec<(MatchingList, String)>, ) -> Self { Self { - whitelist, - blacklist, + message_whitelist, + message_blacklist, + address_blacklist, metrics, send_channels, destination_ctxs, @@ -477,6 +496,7 @@ mod test { db.clone(), Default::default(), Default::default(), + Default::default(), dummy_processor_metrics(origin_domain.id()), HashMap::from([(destination_domain.id(), send_channel)]), HashMap::from([(destination_domain.id(), message_context)]), diff --git a/rust/agents/relayer/src/relayer.rs b/rust/agents/relayer/src/relayer.rs index 3be0f1e59..e8b903153 100644 --- a/rust/agents/relayer/src/relayer.rs +++ b/rust/agents/relayer/src/relayer.rs @@ -33,6 +33,7 @@ use tracing::{error, info, info_span, instrument::Instrumented, warn, Instrument use crate::{ merkle_tree::builder::MerkleTreeBuilder, msg::{ + blacklist::AddressBlacklist, gas_payment::GasPaymentEnforcer, metadata::{BaseMetadataBuilder, IsmAwareAppContextClassifier}, op_submitter::{SerialSubmitter, SerialSubmitterMetrics}, @@ -70,8 +71,9 @@ pub struct Relayer { prover_syncs: HashMap>>, merkle_tree_hook_syncs: HashMap>>, dbs: HashMap, - whitelist: Arc, - blacklist: Arc, + message_whitelist: Arc, + message_blacklist: Arc, + address_blacklist: Arc, transaction_gas_limit: Option, skip_transaction_gas_limit_for: HashSet, allow_local_checkpoint_syncers: bool, @@ -89,11 +91,12 @@ impl Debug for Relayer { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!( f, - "Relayer {{ origin_chains: {:?}, destination_chains: {:?}, whitelist: {:?}, blacklist: {:?}, transaction_gas_limit: {:?}, skip_transaction_gas_limit_for: {:?}, allow_local_checkpoint_syncers: {:?} }}", + "Relayer {{ origin_chains: {:?}, destination_chains: {:?}, message_whitelist: {:?}, message_blacklist: {:?}, address_blacklist: {:?}, transaction_gas_limit: {:?}, skip_transaction_gas_limit_for: {:?}, allow_local_checkpoint_syncers: {:?} }}", self.origin_chains, self.destination_chains, - self.whitelist, - self.blacklist, + self.message_whitelist, + self.message_blacklist, + self.address_blacklist, self.transaction_gas_limit, self.skip_transaction_gas_limit_for, self.allow_local_checkpoint_syncers @@ -177,14 +180,16 @@ impl BaseAgent for Relayer { .map(|(k, v)| (k, v as _)) .collect(); - let whitelist = Arc::new(settings.whitelist); - let blacklist = Arc::new(settings.blacklist); + let message_whitelist = Arc::new(settings.whitelist); + let message_blacklist = Arc::new(settings.blacklist); + let address_blacklist = Arc::new(AddressBlacklist::new(settings.address_blacklist)); let skip_transaction_gas_limit_for = settings.skip_transaction_gas_limit_for; let transaction_gas_limit = settings.transaction_gas_limit; info!( - %whitelist, - %blacklist, + %message_whitelist, + %message_blacklist, + ?address_blacklist, ?transaction_gas_limit, ?skip_transaction_gas_limit_for, "Whitelist configuration" @@ -275,8 +280,9 @@ impl BaseAgent for Relayer { interchain_gas_payment_syncs, prover_syncs, merkle_tree_hook_syncs, - whitelist, - blacklist, + message_whitelist, + message_blacklist, + address_blacklist, transaction_gas_limit, skip_transaction_gas_limit_for, allow_local_checkpoint_syncers: settings.allow_local_checkpoint_syncers, @@ -487,8 +493,9 @@ impl Relayer { let message_processor = MessageProcessor::new( self.dbs.get(origin).unwrap().clone(), - self.whitelist.clone(), - self.blacklist.clone(), + self.message_whitelist.clone(), + self.message_blacklist.clone(), + self.address_blacklist.clone(), metrics, send_channels, destination_ctxs, diff --git a/rust/agents/relayer/src/settings/mod.rs b/rust/agents/relayer/src/settings/mod.rs index 2e6b00573..dc4c1e543 100644 --- a/rust/agents/relayer/src/settings/mod.rs +++ b/rust/agents/relayer/src/settings/mod.rs @@ -8,6 +8,7 @@ use std::{collections::HashSet, path::PathBuf}; use convert_case::Case; use derive_more::{AsMut, AsRef, Deref, DerefMut}; +use ethers::utils::hex; use eyre::{eyre, Context}; use hyperlane_base::{ impl_loadable_from_settings, @@ -46,6 +47,10 @@ pub struct RelayerSettings { pub whitelist: MatchingList, /// Filter for what messages to block. pub blacklist: MatchingList, + /// Filter for what addresses to block interactions with. + /// This is intentionally not an H256 to allow for addresses of any length without + /// adding any padding. + pub address_blacklist: Vec>, /// This is optional. If not specified, any amount of gas will be valid, otherwise this /// is the max allowed gas in wei to relay a transaction. pub transaction_gas_limit: Option, @@ -191,6 +196,14 @@ impl FromRawConf for RelayerSettings { .and_then(parse_matching_list) .unwrap_or_default(); + let address_blacklist = p + .chain(&mut err) + .get_opt_key("addressBlacklist") + .parse_string() + .end() + .map(|str| parse_address_list(str, &mut err, || &p.cwp + "address_blacklist")) + .unwrap_or_default(); + let transaction_gas_limit = p .chain(&mut err) .get_opt_key("transactionGasLimit") @@ -268,6 +281,7 @@ impl FromRawConf for RelayerSettings { gas_payment_enforcement, whitelist, blacklist, + address_blacklist, transaction_gas_limit, skip_transaction_gas_limit_for, allow_local_checkpoint_syncers, @@ -311,3 +325,53 @@ fn parse_matching_list(p: ValueParser) -> ConfigResult { err.into_result(ml) } + +fn parse_address_list( + str: &str, + err: &mut ConfigParsingError, + err_path: impl Fn() -> ConfigPath, +) -> Vec> { + str.split(',') + .filter_map(|s| { + let mut s = s.trim().to_owned(); + if let Some(stripped) = s.strip_prefix("0x") { + s = stripped.to_owned(); + } + hex::decode(s).take_err(err, &err_path) + }) + .collect_vec() +} + +#[cfg(test)] +mod test { + use super::*; + use hyperlane_core::H160; + + #[test] + fn test_parse_address_blacklist() { + let valid_address1 = b"valid".to_vec(); + let valid_address2 = H160::random().as_bytes().to_vec(); + + // Successful parsing + let input = format!( + "0x{}, {}", + hex::encode(&valid_address1), + hex::encode(&valid_address2) + ); + let mut err = ConfigParsingError::default(); + let res = parse_address_list(&input, &mut err, ConfigPath::default); + assert_eq!(res, vec![valid_address1.clone(), valid_address2.clone()]); + assert!(err.is_ok()); + + // An error in the final address provided + let input = format!( + "0x{}, {}, 0xaazz", + hex::encode(&valid_address1), + hex::encode(&valid_address2) + ); + let mut err = ConfigParsingError::default(); + let res = parse_address_list(&input, &mut err, ConfigPath::default); + assert_eq!(res, vec![valid_address1, valid_address2]); + assert!(!err.is_ok()); + } +} diff --git a/rust/hyperlane-base/src/contract_sync/cursors/sequence_aware/forward.rs b/rust/hyperlane-base/src/contract_sync/cursors/sequence_aware/forward.rs index 7314e2a00..ae6629e95 100644 --- a/rust/hyperlane-base/src/contract_sync/cursors/sequence_aware/forward.rs +++ b/rust/hyperlane-base/src/contract_sync/cursors/sequence_aware/forward.rs @@ -546,10 +546,10 @@ pub(crate) mod test { pub sequence: u32, } - impl Into> for MockSequencedData { - fn into(self) -> Indexed { - let sequence = self.sequence; - Indexed::new(self).with_sequence(sequence) + impl From for Indexed { + fn from(val: MockSequencedData) -> Self { + let sequence = val.sequence; + Indexed::new(val).with_sequence(sequence) } } diff --git a/typescript/infra/config/environments/mainnet3/agent.ts b/typescript/infra/config/environments/mainnet3/agent.ts index 566986588..32c5831e4 100644 --- a/typescript/infra/config/environments/mainnet3/agent.ts +++ b/typescript/infra/config/environments/mainnet3/agent.ts @@ -223,7 +223,7 @@ const hyperlane: RootAgentConfig = { rpcConsensusType: RpcConsensusType.Fallback, docker: { repo, - tag: '3bb9d0a-20240619-130157', + tag: '3fddaeb-20240619-163111', }, gasPaymentEnforcement: gasPaymentEnforcement, metricAppContexts, diff --git a/typescript/infra/src/config/agent/relayer.ts b/typescript/infra/src/config/agent/relayer.ts index 3d88c16ff..f9d4bac73 100644 --- a/typescript/infra/src/config/agent/relayer.ts +++ b/typescript/infra/src/config/agent/relayer.ts @@ -1,4 +1,6 @@ import { BigNumberish } from 'ethers'; +import { Logger } from 'pino'; +import { z } from 'zod'; import { AgentConfig, @@ -10,7 +12,13 @@ import { MatchingList, RelayerConfig as RelayerAgentConfig, } from '@hyperlane-xyz/sdk'; -import { Address, ProtocolType, addressToBytes32 } from '@hyperlane-xyz/utils'; +import { + Address, + ProtocolType, + addressToBytes32, + isValidAddressEvm, + rootLogger, +} from '@hyperlane-xyz/utils'; import { getChain, getDomainId } from '../../../config/registry.js'; import { AgentAwsUser } from '../../agents/aws/user.js'; @@ -34,6 +42,7 @@ export interface BaseRelayerConfig { gasPaymentEnforcement: GasPaymentEnforcement[]; whitelist?: MatchingList; blacklist?: MatchingList; + addressBlacklist?: string; transactionGasLimit?: BigNumberish; skipTransactionGasLimitFor?: string[]; metricAppContexts?: MetricAppContext[]; @@ -58,12 +67,15 @@ export interface HelmRelayerChainValues { export class RelayerConfigHelper extends AgentConfigHelper { readonly #relayerConfig: BaseRelayerConfig; + readonly logger: Logger; constructor(agentConfig: RootAgentConfig) { if (!agentConfig.relayer) throw Error('Relayer is not defined for this context'); super(agentConfig, agentConfig.relayer); + this.#relayerConfig = agentConfig.relayer; + this.logger = rootLogger.child({ module: 'RelayerConfigHelper' }); } async buildConfig(): Promise { @@ -80,6 +92,11 @@ export class RelayerConfigHelper extends AgentConfigHelper { if (baseConfig.blacklist) { relayerConfig.blacklist = JSON.stringify(baseConfig.blacklist); } + + relayerConfig.addressBlacklist = (await this.getSanctionedAddresses()).join( + ',', + ); + if (baseConfig.transactionGasLimit) { relayerConfig.transactionGasLimit = baseConfig.transactionGasLimit.toString(); @@ -130,6 +147,40 @@ export class RelayerConfigHelper extends AgentConfigHelper { return chainSigners; } + async getSanctionedAddresses() { + // All Ethereum-style addresses from https://github.com/0xB10C/ofac-sanctioned-digital-currency-addresses/tree/lists + const currencies = ['ARB', 'ETC', 'ETH', 'USDC', 'USDT']; + + const schema = z.array(z.string()); + + const allSanctionedAddresses = await Promise.all( + currencies.map(async (currency) => { + const rawUrl = `https://raw.githubusercontent.com/0xB10C/ofac-sanctioned-digital-currency-addresses/lists/sanctioned_addresses_${currency}.json`; + this.logger.debug( + { + currency, + rawUrl, + }, + 'Fetching sanctioned addresses', + ); + const json = await fetch(rawUrl); + const sanctionedAddresses = schema.parse(await json.json()); + return sanctionedAddresses; + }), + ); + + return allSanctionedAddresses.flat().filter((address) => { + if (!isValidAddressEvm(address)) { + this.logger.debug( + { address }, + 'Invalid sanctioned address, throwing out', + ); + return false; + } + return true; + }); + } + // Returns whether the relayer requires AWS credentials get requiresAwsCredentials(): boolean { // If AWS is present on the agentConfig, we are using AWS keys and need credentials regardless. diff --git a/typescript/sdk/src/metadata/agentConfig.ts b/typescript/sdk/src/metadata/agentConfig.ts index 49f05e883..ae20d551b 100644 --- a/typescript/sdk/src/metadata/agentConfig.ts +++ b/typescript/sdk/src/metadata/agentConfig.ts @@ -314,6 +314,10 @@ export const RelayerAgentConfigSchema = AgentConfigSchema.extend({ .describe( 'If no blacklist is provided ALL will be considered to not be on the blacklist.', ), + addressBlacklist: z + .string() + .optional() + .describe('Comma separated list of addresses to blacklist.'), transactionGasLimit: ZUWei.optional().describe( 'This is optional. If not specified, any amount of gas will be valid, otherwise this is the max allowed gas in wei to relay a transaction.', ),