Skip to content
Open
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
149 changes: 148 additions & 1 deletion crates/common/src/dotrain_order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"] {
Expand Down Expand Up @@ -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![]);
Expand Down
Loading