Skip to content

fix(node): reject non-http transaction relay URLs - #336

Open
Kewe63 wants to merge 1 commit into
circlefin:mainfrom
Kewe63:fix-335-reject-non-http-tx-relays
Open

fix(node): reject non-http transaction relay URLs#336
Kewe63 wants to merge 1 commit into
circlefin:mainfrom
Kewe63:fix-335-reject-non-http-tx-relays

Conversation

@Kewe63

@Kewe63 Kewe63 commented Sep 4, 2026

Copy link
Copy Markdown

Summary

Fixes #335

  • Validate the transport scheme of every --arc.tx.relays entry
  • Accept only http and https relay URLs
  • Reject syntactically valid but unsupported file, ftp, ws, and wss URLs during startup
  • Preserve relay ordering, whitespace trimming, blank-entry handling, and existing malformed-URL errors
  • Add focused regression coverage for supported and unsupported schemes

Problem

build_tx_relays() previously validated relay entries using only:

url::Url::parse(s)

This verifies URL syntax but not compatibility with the downstream transport.

As a result, configurations such as:

--arc.tx.relays file:///tmp/upstream

were accepted and returned as valid relay entries.

The downstream implementation constructs each relay with an explicitly HTTP-based client:

RpcClient::new_http_with_client(...)

using reqwest::Client. Unsupported schemes therefore failed only when transaction forwarding was attempted, rather than being rejected during startup validation.


Changes

build_tx_relays() now:

  1. parses each relay URL as before;
  2. checks the parsed URL scheme;
  3. accepts only http and https;
  4. returns an invalid --arc.tx.relays entry error for unsupported schemes;
  5. preserves the original trimmed URL and configured relay order.

No forwarding, failover, timeout, or RPC client behavior was changed.


Regression Coverage

The existing relay-order test now verifies a mixed HTTP/HTTPS list:

http://a:8545,https://b.example

A new regression test verifies that the following schemes are rejected:

file:///tmp/upstream
ftp://relay.example
ws://relay.example
wss://relay.example

The existing tests for malformed URLs, trimming, blank entries, relay order, timeout parsing, and --rpc.forwarder conflicts remain in place.


RED Verification

Before the production fix, the unsupported-scheme regression test failed:

transaction relay upstreams must use HTTP or HTTPS:
["file:///tmp/upstream"]

test tests::test_build_tx_relays_rejects_non_http_urls ... FAILED

The HTTP/HTTPS acceptance test continued to pass.


How to Test

cargo +1.94.0 test -p arc-node-execution transaction_relay -- --nocapture
cargo +1.94.0 test -p arc-node-execution --bin arc-node-execution --tests
cargo clippy -p arc-node-execution --bin arc-node-execution --tests -- -D warnings
cargo fmt -p arc-node-execution -- --check
git diff --check

Results:

  • Focused transaction-relay tests: 5 passed
  • Complete arc-node-execution binary tests: 65 passed
  • cargo clippy: passed
  • cargo fmt: passed
  • git diff --check: passed

Note: The final linked test binary was built with reduced linker memory usage because the standard test link exceeded the available WSL memory:

CARGO_BUILD_JOBS=1 cargo +1.94.0 rustc
-p arc-node-execution
--bin arc-node-execution
--profile test
--
-C debuginfo=0
-C link-arg=-Wl,--no-keep-memory

Local verification used Rust 1.94.0 because the local pinned 1.93.0 installation is currently broken. CI should provide the authoritative pinned-toolchain result.


Scope and Risk

Risk is low.

The change is limited to the CLI configuration boundary in:

crates/node/src/main.rs

It does not alter:

  • transaction forwarding
  • relay failover or sticky selection
  • request timeout behavior
  • response handling
  • RPC client construction
  • consensus or protocol behavior

Checklist

  • Bug reproduced on current main
  • Regression test confirmed failing before the fix
  • HTTP and HTTPS relay URLs remain accepted
  • Unsupported schemes are rejected during startup
  • Focused and complete binary tests pass
  • Formatting and clippy checks pass
  • No unrelated files changed
  • Follows Conventional Commits
  • Independent read-only review: approved with no blocking findings

Impact

Type: 🐛 Bug fix
Fixes: #335

@osr21 osr21 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disclosure: I'm not affiliated with Circle — an external community contributor, not a maintainer. I have no write access to this repository, so the APPROVED state on this review carries no merge authority and is advisory only. Please treat it as one contributor's technical assessment and defer to Circle maintainers for the binding review.


Reviewed at 3eb2185 against base 97f8da0. I verified the underlying gap in detail on #335, so this focuses on the patch itself. Source review only — no Rust toolchain in my environment, so I traced types and call paths rather than running cargo nextest.

The fix is correct and the scheme predicate has no hole. Since rust-url and Node's URL both implement the WHATWG URL Standard, I ran the awkward inputs through equivalent parser semantics. The two that look like holes aren't: http:///tmp/upstream collapses the extra slash to host tmp, and http:a:8545 normalizes to http://a:8545/ — both are genuine HTTP URLs reqwest can drive, so accepting them is right. Empty-host (http://) and out-of-range-port (http://a:99999) never reach the scheme check because Url::parse rejects them first. Scheme is lowercased during parsing, so HTTPS://... is not falsely rejected. matches!(url.scheme(), "http" | "https") is exactly the right predicate.

It is non-breaking against the existing suite. I checked each sibling test: the default-empty, trim/skip-blanks, not-a-url, and --rpc.forwarder conflict tests are all unaffected, and the modified order test still asserts ordering. Because validation happens after clap, the check also covers values supplied via ARC_TX_RELAYS, not just the flag — worth stating explicitly since the env path is documented in docs/tx-forwarding.md and is easy to overlook.

And the new test will actually run: .github/workflows/ci.yml:91 executes cargo nextest run --locked --workspace --exclude arc-test-integration, which covers this crate. That's not true of every test directory in this repo, so it's worth noting the coverage lands where it counts.

The one thing I'd want resolved before merge is a changelog entry, not a code change

This converts a configuration that currently starts into one that aborts at startup. A node with, say, --arc.tx.relays http://good:8545,ws://stale.example runs today — the bad entry just fails at forward time and failover skips past it — and after this change refuses to boot. Fail-fast is the right call, but it is an operator-visible behaviour change rather than a pure bug fix.

The repo already has a settled convention for precisely this class of change. CHANGELOG.md v0.8.0 carries:

[Config] Explicit invalid CL environment values now fail startup. … malformed values and zero values for settings that must be positive abort startup.

…under ### For Node Operators, with a matching BREAKING_CHANGES.md section. This PR is the same shape and currently adds no changelog entry at all.

That said, I checked the blast radius and it is genuinely small, which is why I'm approving rather than requesting changes. --arc.tx.relays does not exist in v0.6.0, v0.7.1, v0.7.2, or v0.7.3 — it first appears in v0.8.0, the current release. So the entire population of affected operators is those who adopted a brand-new flag and pointed it at a non-HTTP URL that has never worked. A ### Fixes line is probably proportionate; I'd only escalate to ### For Node Operators + BREAKING_CHANGES.md if maintainers read the v0.8.0 adoption as non-trivial.

This recency actually argues for merging sooner rather than later: the tightening is cheapest now, while almost nobody could be relying on the lenient behaviour.

Three non-blocking suggestions

1. State the constraint where operators will look for it. The clap doc comment for arc_tx_relays (crates/node/src/main.rs:297) describes ordering and failover but says nothing about permitted schemes, so --help won't explain the new rejection. One line — "Each URL must use HTTP or HTTPS." — closes the loop between the error and the documentation. docs/tx-forwarding.md:83 has the same gap; its examples are all https://, which implies the rule without stating it.

2. Pin the mixed valid/invalid case. Nothing asserts what http://good:8545,file:///bad does. Because the closure collects into eyre::Result<Vec<String>>, collect() short-circuits on the first Err and the whole list is rejected rather than the bad entry being dropped and the good one kept. That is the correct behaviour and it is the single thing distinguishing fail-fast from silent-skip — but a future refactor toward leniency would keep every test in this PR green while reversing the intent.

3. Update the contract comment in TxRelays::new. crates/evm-node/src/rpc_middleware.rs:507 still reads "URLs are pre-validated at the CLI boundary; any that fail to parse here are skipped." After this PR the guarantee is stronger — parses and is HTTP(S). That comment is the written contract between the two layers and is what a future reader will trust instead of re-deriving the CLI validation, so leaving it stale is the most likely route to this gap reappearing. Worth noting the inner filter_map silently drops unparseable URLs and (!clients.is_empty()).then(...) returns None if all are dropped — meaning the layer below still degrades quietly. This PR makes that unreachable from the CLI path; the comment should say so.

Clean, minimal, correctly scoped patch with a test that lands in CI — approving on the code. The changelog line is the only thing I'd genuinely like to see added.

@huklaa

huklaa commented Sep 4, 2026

Copy link
Copy Markdown

Nice fix. I found two small follow-up points that would make the regression coverage and contract clearer:

  1. Add a mixed valid/invalid relay case, e.g. http://good:8545,file:///bad, and assert that the whole configuration is rejected rather than silently keeping the valid entry.

  2. Update the TxRelays::new doc comment in crates/evm-node/src/rpc_middleware.rs so it states that relay URLs are pre-validated as HTTP/HTTPS at the CLI boundary, not merely syntactically parsed.

These are small, but they would better lock in the intended fail-fast behavior and prevent the old silent-skip semantics from creeping back in.

@osr21

osr21 commented Sep 4, 2026

Copy link
Copy Markdown

Disclosure: I'm not affiliated with Circle — an external community contributor, not a maintainer, with no write access. My approval above carries no merge authority and is advisory only; please defer to Circle maintainers for the binding review.


@huklaa — agreed on both, and they're worth doing. For @Kewe63's benefit: these are the same two items as suggestions #2 and #3 in my review above from earlier today, so the outstanding list hasn't grown — it's still those two, plus the --help/docs line, plus the changelog entry.

One correction on the framing, because it changes what the mixed test is actually for.

There were never silent-skip semantics at the CLI boundary

build_tx_relays has always been fail-fast. Pre-PR, on main:

// crates/node/src/main.rs:397-408 (base)
.map(|s| {
    url::Url::parse(s)
        .map(|_| s.to_string())
        .map_err(|e| eyre::eyre!("invalid --arc.tx.relays entry {s:?}: {e}"))
})
.collect()   // -> eyre::Result<Vec<String>>, short-circuits on first Err

Collecting into Result<Vec<_>> aborts the whole list on the first error, so http://good:8545,not-a-url already rejected the entire configuration rather than keeping the good entry — and test_build_tx_relays_rejects_invalid_url (main.rs:1044) already asserted it. This PR preserves that shape exactly, just adding the scheme arm inside the same closure.

The silent-skip you're thinking of is real, but it lives one layer down in TxRelays::new:

// crates/evm-node/src/rpc_middleware.rs:520-530
.filter_map(|u| match reqwest::Url::parse(u) {
    Ok(url) => Some(RpcClient::new_http_with_client(http.clone(), url)),
    Err(e) => { tracing::warn!(...); None }        // <- drops
})
...
(!clients.is_empty()).then(|| Self { ... })        // <- None = relaying silently disabled

Two layers, opposite semantics. Practical consequence: the mixed valid/invalid test can't be regression coverage in the usual sense — there's no lost behaviour to restore, and anyone who goes looking for one in git log will come up empty. Its value is forward-looking: it pins fail-fast against a future refactor toward leniency (the natural one being someone swapping collect() for filter_map to "tolerate one bad relay"), which would keep every test currently in this PR green while reversing the intent. Worth adding for that reason, and worth the test name saying so.

On the doc comment — the stale clause is the second one, not the first

The comment at rpc_middleware.rs:506-508 makes two claims:

URLs are pre-validated at the CLI boundary; any that fail to parse here are skipped.

This PR strengthens the first (now: parses and is HTTP/HTTPS). The second stays literally true as a description of the code — filter_map still drops, and an all-drop list still yields None. So rewriting it to say only "pre-validated as HTTP/HTTPS" would leave the misleading half in place.

I checked the reachability: TxRelays::new has exactly one caller in-tree (rpc_middleware.rs:132), and the tx_relays it receives originates solely from build_tx_relays (main.rs:610, threaded through at :626). There is no second construction path. So post-merge the skip branch is genuinely unreachable, and the honest wording is something like — pre-validated as HTTP/HTTPS at the CLI boundary, its only caller; the parse-skip below is defence-in-depth and currently unreachable. That documents the contract and explains why dead-looking code is being kept, which is the bit a future reader would otherwise "clean up".

The changelog entry is still the one thing I'd want before merge

Neither of these two points covers it, so flagging that it hasn't been addressed. CHANGELOG.md currently has no Unreleased section — v0.8.0 is at the top, where --arc.tx.relays was introduced under Features ("Add ordered transaction relay failover for follow nodes"). That confirms the small blast radius I described earlier: the flag has existed for exactly one release, so a ### Fixes line is proportionate and the tightening is cheapest now.

My approval stands — none of this is a code-correctness objection. Same caveat as before: no cargo/rustc available, so this is source review plus git archaeology across main and the PR head, not a test run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transaction relay configuration should reject non-HTTP upstream URLs

3 participants