From 6ba3d17284dc620adf8605b2825bc1236e9abd7d Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 13 Jun 2026 10:38:25 +0000 Subject: [PATCH] Strip RPC URLs from generated dotrain to prevent API token leak (#2165) generate_dotrain_for_deployment() copied the deployment network's `networks` section verbatim into the dotrain it produces. That dotrain is shared and embedded into on-chain order metadata, so any private API token baked into a user's RPC URL (path segment or query string) leaked to everyone who could read the order. Following the existing vault-id stripping pattern, replace every RPC URL in the emitted network entry with a non-secret placeholder before returning. The placeholder is a parseable URL so the required, non-empty `rpcs` array stays structurally valid and the generated dotrain still parses; consumers (webapp / local settings) inject their own RPCs at use time. Adds a discriminating test that embeds two secrets (a path-style API key and a query token) in the network RPC URLs and asserts neither secret, nor the secret-bearing URLs, appears anywhere in the generated dotrain, that each rpc entry is replaced by the placeholder, and that the result still parses. The test fails against the unpatched function (secret leaks through). Co-Authored-By: Claude Opus 4.8 --- crates/common/src/dotrain_order.rs | 149 ++++++++++++++++++++++++++++- 1 file changed, 148 insertions(+), 1 deletion(-) diff --git a/crates/common/src/dotrain_order.rs b/crates/common/src/dotrain_order.rs index 40f56bd146..5a6fd9269c 100644 --- a/crates/common/src/dotrain_order.rs +++ b/crates/common/src/dotrain_order.rs @@ -25,6 +25,14 @@ use strict_yaml_rust::{strict_yaml::Hash as StrictYamlHash, StrictYaml, StrictYa use thiserror::Error; use wasm_bindgen_utils::prelude::*; +/// Placeholder substituted for every RPC URL in the networks section of a +/// dotrain generated for deployment. User RPC URLs can embed private API tokens, +/// so they are never emitted into the shared/on-chain dotrain; consumers +/// (e.g. the webapp or local settings) inject their own RPCs at use time. The +/// value must remain a parseable, non-empty URL because `rpcs` is a required +/// network field. +const STRIPPED_RPC_PLACEHOLDER: &str = "https://rpc.example.com"; + /// DotrainOrder represents a parsed and validated dotrain configuration that combines /// YAML frontmatter with Rainlang code for raindex operations. /// @@ -661,8 +669,9 @@ impl DotrainOrder { StrictYaml::String(spec_version.to_string()), ); - let network_value = clone_section_entry(&documents, "networks", &network_key) + let mut network_value = clone_section_entry(&documents, "networks", &network_key) .map_err(|err| DotrainOrderError::CleanUnusedFrontmatterError(err.to_string()))?; + Self::strip_rpcs_from_network(&mut network_value); let mut networks_hash = StrictYamlHash::new(); networks_hash.insert(StrictYaml::String(network_key.clone()), network_value); root_hash.insert( @@ -817,6 +826,22 @@ impl DotrainOrder { Ok(Some(StrictYaml::Hash(builder_hash))) } + /// Replace every RPC URL in a network entry with a non-secret placeholder. + /// User RPC URLs can carry private API tokens, so they must never end up in + /// the dotrain that is shared or written to on-chain order metadata. The + /// placeholder keeps the required, non-empty `rpcs` array structurally + /// valid so the generated dotrain still parses. + fn strip_rpcs_from_network(network_yaml: &mut StrictYaml) { + if let StrictYaml::Hash(network_hash) = network_yaml { + let rpcs_key = StrictYaml::String("rpcs".to_string()); + if let Some(StrictYaml::Array(rpcs)) = network_hash.get_mut(&rpcs_key) { + for rpc in rpcs.iter_mut() { + *rpc = StrictYaml::String(STRIPPED_RPC_PLACEHOLDER.to_string()); + } + } + } + } + fn strip_vault_ids_from_order(order_yaml: &mut StrictYaml) { if let StrictYaml::Hash(order_hash) = order_yaml { for section in ["inputs", "outputs"] { @@ -1298,6 +1323,128 @@ deployments: )); } + #[tokio::test] + async fn test_generate_dotrain_for_deployment_strips_rpc_api_tokens() { + // Two distinct secrets embedded in the RPC URLs: a path-style API key + // and a query-string token. A correct implementation must emit neither + // into the generated dotrain. + let secret_path_key = "deadbeefcafebabe0123456789abcdef"; + let secret_query_token = "sk_live_TOPSECRET_TOKEN_42"; + let rpc_with_path = format!("https://eth-mainnet.example.com/v2/{secret_path_key}"); + let rpc_with_query = format!("https://rpc.example.org/?apikey={secret_query_token}"); + + let dotrain = format!( + r#" +version: {spec_version} +networks: + polygon: + rpcs: + - {rpc_with_path} + - {rpc_with_query} + chain-id: 137 + network-id: 137 + currency: MATIC +rainlangs: + polygon: + address: 0x1234567890123456789012345678901234567890 +orders: + polygon-order: + network: polygon + inputs: + - token: t1 + vault-id: 1 + outputs: + - token: t2 + vault-id: 2 +tokens: + t1: + network: polygon + address: 0x1111111111111111111111111111111111111111 + t2: + network: polygon + address: 0x2222222222222222222222222222222222222222 +deployments: + polygon-deployment: + scenario: polygon + order: polygon-order +scenarios: + polygon: + rainlang: polygon +--- +#calculate-io +_ _: 0 0; +#handle-io +:;"#, + spec_version = SpecVersion::current() + ); + + let dotrain_order = DotrainOrder::create(dotrain.to_string(), None) + .await + .unwrap(); + + let generated = dotrain_order + .generate_dotrain_for_deployment("polygon-deployment") + .unwrap(); + + // SECURITY: neither secret token, nor the full secret-bearing URLs, may + // appear anywhere in the generated dotrain. + assert!( + !generated.contains(secret_path_key), + "path API key leaked into generated dotrain:\n{generated}" + ); + assert!( + !generated.contains(secret_query_token), + "query API token leaked into generated dotrain:\n{generated}" + ); + assert!( + !generated.contains(&rpc_with_path), + "secret RPC URL leaked into generated dotrain:\n{generated}" + ); + assert!( + !generated.contains(&rpc_with_query), + "secret RPC URL leaked into generated dotrain:\n{generated}" + ); + + // The required rpcs array is preserved structurally, replaced with the + // non-secret placeholder (one per original entry). + let (frontmatter, _body) = split_frontmatter_and_body(&generated); + let root = get_root_hash(&frontmatter); + let StrictYaml::Hash(networks) = root + .get(&StrictYaml::String("networks".to_string())) + .expect("networks present") + .clone() + else { + panic!("networks not a hash"); + }; + let StrictYaml::Hash(polygon) = networks + .get(&StrictYaml::String("polygon".to_string())) + .expect("polygon network present") + .clone() + else { + panic!("polygon network not a hash"); + }; + let StrictYaml::Array(rpcs) = polygon + .get(&StrictYaml::String("rpcs".to_string())) + .expect("rpcs present") + .clone() + else { + panic!("rpcs not an array"); + }; + assert_eq!(rpcs.len(), 2, "every rpc entry should be preserved"); + for rpc in &rpcs { + assert_eq!( + rpc, + &StrictYaml::String(STRIPPED_RPC_PLACEHOLDER.to_string()), + "every rpc should be replaced with the placeholder" + ); + } + + // The placeholder must keep the dotrain parseable end-to-end. + DotrainOrder::create(generated, None) + .await + .expect("generated dotrain with stripped rpcs should still parse"); + } + #[tokio::test] async fn test_rainlang_post_from_scenario() { let server = mock_server(vec![]);