Skip to content

Security: unauthenticated RPC clients can add or remove persistent P2P peers #208

Description

@mssystem1

Summary

The Arc consensus REST API exposes state-changing endpoints that allow any network client to modify the node’s persistent peer configuration:

POST /persistent-peers
DELETE /persistent-peers

The RPC router does not apply authentication or authorization middleware before these handlers are registered.

This becomes remotely exploitable whenever operators bind the RPC server to a non-loopback interface, which the Arc documentation explicitly demonstrates using:

--rpc.addr=0.0.0.0:31000

An unauthenticated client that can reach the RPC port can therefore add attacker-controlled persistent peers or remove legitimate peers at runtime.

Repository

circlefin/arc-node

Create issue:

https://github.com/circlefin/arc-node/issues/new

Affected files

RPC routes

crates/malachite-app/src/rpc/routes.rs

"/network-state",
crate::rpc::handlers::get_network_state,
"Get the current network state (peers, topics, scores)"
),
route!(
post,
"/persistent-peers",
crate::rpc::handlers::add_persistent_peer,
"Add a persistent peer at runtime.",
params = {
"body" => "JSON object with \"addr\" (string): multiaddr of the peer, e.g. \"/ip4/127.0.0.1/tcp/26656/p2p/12D3KooW...\"."
}
),
route!(
delete,
"/persistent-peers",
crate::rpc::handlers::remove_persistent_peer,
"Remove a persistent peer at runtime.",
params = {
"body" => "JSON object with \"addr\" (string): multiaddr of the peer to remove, e.g. \"/ip4/127.0.0.1/tcp/26656/p2p/12D3KooW...\"."
}
),
];
#[tracing::instrument(name = "rpc", skip_all)]

The mutating routes are registered as:

route!(
    post,
    "/persistent-peers",
    crate::rpc::handlers::add_persistent_peer,
    "Add a persistent peer at runtime."
),
route!(
    delete,
    "/persistent-peers",
    crate::rpc::handlers::remove_persistent_peer,
    "Remove a persistent peer at runtime."
),

Router construction

/// This is exposed publicly for testing purposes, allowing integration tests
/// to create a server with the actual production router.
pub fn build_router(
tx_consensus_req: TxConsensusReq,
tx_app_req: TxAppReq,
tx_network_req: TxNetworkReq,
) -> Router {
let rpc_state = RpcState {
tx_consensus_req,
tx_app_req,
tx_network_req,
};
let routes = build_routes();
let mut router = Router::new();
for route in &routes {
router = router.route(route.path, (route.handler)());
}
let docs = routes
.into_iter()
.map(|r| (format!("{} {}", r.method, r.path), r.doc))
.collect::<BTreeMap<_, _>>();
router = {
let docs = Arc::new(docs);
router.route("/", get(move || get_index(Arc::clone(&docs))))
};
// Apply version extraction middleware
router
.layer(axum::middleware::from_fn(extract_version))
.with_state(rpc_state)
}
async fn inner(

The router applies version extraction middleware, but no authentication or authorization layer:

router
    .layer(axum::middleware::from_fn(extract_version))
    .with_state(rpc_state)

Handlers

crates/malachite-app/src/rpc/handlers.rs

Add persistent peer:

Extension(version): Extension<ApiVersion>,
Json(body): Json<AddOrRemovePersistentPeerBody>,
) -> impl IntoResponse {
let addr: malachitebft_app_channel::app::net::Multiaddr = match body.addr.parse() {
Ok(a) => a,
Err(_) => {
let body = Json(json!({"error": "Invalid multiaddr"}));
return (StatusCode::BAD_REQUEST, body).into_response();
}
};
tracing::debug!(?version, ?addr, "add_persistent_peer called");
// For future ref: https://github.com/circlefin/malachite/pull/1485
// let has_p2p = addr.iter().any(|p| matches!(p, multiaddr::Protocol::P2p(_)));
// if !has_p2p {
// let body = Json(json!({
// "error": "Multiaddr must include /p2p/<peer_id>, e.g. /ip4/127.0.0.1/tcp/26656/p2p/12D3KooW..."
// }));
// return (StatusCode::BAD_REQUEST, body).into_response();
// }
match NetworkRequest::add_persistent_peer(&tx_network_req, addr).await {
Ok(Ok(())) => (StatusCode::OK, Json(json!({ "status": "ok" }))).into_response(),
Ok(Err(e)) => persistent_peer_error_to_response(e).into_response(),
Err(e) => request_error_to_response(e).into_response(),
}
}
pub(crate) async fn remove_persistent_peer(

Remove persistent peer:

https://github.com/circlefin/arc-node/blob/745ba4eac61a553560a77c8b384c75aa6342da3f/crates/malachite-app/src/rpc/handlers.rs#L237-L256

The handlers directly forward the supplied multiaddress to the networking subsystem:

NetworkRequest::add_persistent_peer(&tx_network_req, addr).await

and:

NetworkRequest::remove_persistent_peer(&tx_network_req, addr).await

No caller identity, token, source-address restriction, or operator permission is checked.

Documentation showing remote exposure

crates/malachite-app/README.md

```bash
arc-node-consensus start \
--home=~/.arc/consensus \
--moniker=validator-1 \
--validator \
--suggested-fee-recipient=0xYourAddressHere \
--p2p.addr=/ip4/172.19.0.5/tcp/27000 \
--p2p.persistent-peers=/ip4/172.19.0.6/tcp/27000,/ip4/172.19.0.7/tcp/27000 \
--metrics=172.19.0.5:29000 \
--rpc.addr=0.0.0.0:31000 \
--eth-socket=/tmp/reth.ipc \
--execution-socket=/tmp/auth.ipc \
--minimal
```
**Full example with RPC** (for remote deployments):
```bash
arc-node-consensus start \
--home=~/.arc/consensus \
--moniker=validator-1 \
--validator \
--suggested-fee-recipient=0xYourAddressHere \
--p2p.addr=/ip4/172.19.0.5/tcp/27000 \
--p2p.persistent-peers=/ip4/172.19.0.6/tcp/27000,/ip4/172.19.0.7/tcp/27000 \
--metrics=0.0.0.0:29000 \
--rpc.addr=0.0.0.0:31000 \
--eth-rpc-endpoint=http://localhost:8545 \
--execution-endpoint=http://localhost:8551 \
--execution-jwt=jwtsecret \
--minimal
```

The documented validator configurations bind RPC to all interfaces:

--rpc.addr=0.0.0.0:31000

The REST API documentation also describes binding the endpoint to all interfaces:

The consensus layer exposes a REST API for monitoring and querying consensus state when `--rpc.addr` is set (e.g., `--rpc.addr=0.0.0.0:26658`).
### API Versioning

--rpc.addr=0.0.0.0:26658

Steps to reproduce

Start an Arc consensus node with an externally reachable RPC endpoint:

arc-node-consensus start \
  --rpc.addr=0.0.0.0:31000 \
  [other required arguments]

From another host, submit an unauthenticated request:

curl -X POST \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/vnd.arc.v1+json' \
  http://NODE_IP:31000/persistent-peers \
  -d '{
    "addr": "/ip4/ATTACKER_IP/tcp/27000/p2p/ATTACKER_PEER_ID"
  }'

The request reaches NetworkRequest::add_persistent_peer() without credentials.

A legitimate persistent peer can likewise be removed:

curl -X DELETE \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/vnd.arc.v1+json' \
  http://NODE_IP:31000/persistent-peers \
  -d '{
    "addr": "/ip4/LEGITIMATE_PEER_IP/tcp/27000/p2p/LEGITIMATE_PEER_ID"
  }'

Expected behavior

Runtime operations that modify the node’s networking configuration should be restricted to authorized operators.

Read-only monitoring endpoints may remain public, but peer-management endpoints should require authentication or should only be available through a separate administrative interface bound to localhost by default.

Actual behavior

Any client capable of reaching the RPC listener can invoke the persistent-peer mutation handlers.

The Accept header only selects the API version and does not authenticate the caller.

Impact

An attacker with network access to the RPC port may be able to:

  • add attacker-controlled persistent peers;
  • remove trusted persistent peers;
  • repeatedly alter the node’s peer configuration;
  • influence which nodes receive persistent connection attempts;
  • degrade node connectivity;
  • interfere with sentry-node topology;
  • increase exposure to eclipse-style isolation attempts;
  • trigger repeated outbound connection work;
  • disrupt validators that depend on a restricted persistent-peer set.

The impact is greater for validators using:

--p2p.persistent-peers-only

because removing or replacing the configured persistent peers may directly affect the validator’s available network topology.

Suggested fix

Separate public monitoring RPC from privileged administrative RPC.

At minimum:

  1. Require authentication for POST /persistent-peers and DELETE /persistent-peers.

  2. Bind administrative endpoints to loopback by default.

  3. Do not register mutating routes on the public REST listener unless explicitly enabled.

  4. Add a dedicated configuration flag, for example:

    --rpc.admin
    --rpc.admin-addr=127.0.0.1:26659
    --rpc.admin-token-file=/path/to/token
    
  5. Support TLS or deployment behind an authenticated reverse proxy when remote administration is required.

  6. Clearly document that the public RPC interface must not expose administrative routes.

A simple bearer-token middleware would be an improvement, but a separate admin listener would provide stronger isolation.

Tests

Add integration tests confirming that:

  • unauthenticated POST /persistent-peers is rejected;
  • unauthenticated DELETE /persistent-peers is rejected;
  • read-only routes remain accessible as intended;
  • valid administrative credentials permit peer updates;
  • invalid or missing credentials return 401 or 403;
  • administrative routes are not registered when admin RPC is disabled;
  • the default administrative listener uses a loopback address.

Acceptance criteria

  • Peer mutation endpoints are no longer publicly callable without authorization
  • Administrative routes are separated from public monitoring routes
  • Secure bind defaults are used
  • Authentication tests cover add and remove operations
  • Documentation identifies the administrative security boundary
  • Existing public read-only RPC compatibility is preserved

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions