Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: filter RPC methods from daemon #5254

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
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
29 changes: 28 additions & 1 deletion .github/workflows/forest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Integration tests
concurrency:
group: '${{ github.workflow }}-${{ github.ref }}'
cancel-in-progress: '${{ github.ref != ''refs/heads/main'' }}'
'on':
on:
workflow_dispatch:
merge_group:
pull_request:
Expand Down Expand Up @@ -185,6 +185,32 @@ jobs:
chmod +x ~/.cargo/bin/forest*
- run: ./scripts/tests/calibnet_stateless_mode_check.sh
timeout-minutes: '${{ fromJSON(env.SCRIPT_TIMEOUT_MINUTES) }}'
calibnet-stateless-rpc-check:
needs:
- build-ubuntu
name: Calibnet stateless RPC check
runs-on: ubuntu-24.04
steps:
- run: lscpu
- uses: actions/cache@v4
with:
path: '${{ env.FIL_PROOFS_PARAMETER_CACHE }}'
key: proof-params-keys
- name: Checkout Sources
uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: 'forest-${{ runner.os }}'
path: ~/.cargo/bin
- uses: actions/download-artifact@v4
with:
name: 'forest-${{ runner.os }}'
path: ~/.cargo/bin
- name: Set permissions
run: |
chmod +x ~/.cargo/bin/forest*
- run: ./scripts/tests/calibnet_stateless_rpc_check.sh
timeout-minutes: '${{ fromJSON(env.SCRIPT_TIMEOUT_MINUTES) }}'
state-migrations-check:
needs:
- build-ubuntu
Expand Down Expand Up @@ -558,6 +584,7 @@ jobs:
- forest-cli-check
- calibnet-check
- calibnet-stateless-mode-check
- calibnet-stateless-rpc-check
- state-migrations-check
- calibnet-wallet-check
- calibnet-export-check
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@

- [#4769](https://github.com/ChainSafe/forest/issues/4769) Add delegated address support to `forest-wallet new` command.

- [#5147](https://github.com/ChainSafe/forest/issues/5147) Add support for the `--rpc-filter-list` flag to the `forest` daemon. This flag allows users to specify a list of RPC methods to whitelist or blacklist.

### Changed

- [#5237](https://github.com/ChainSafe/forest/pull/5237) Stylistic changes to FIL pretty printing.
Expand Down
1 change: 1 addition & 0 deletions docs/dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ M2
macOS
Mainnet
mainnet
namespace
NV22
NV23
NV24
Expand Down
89 changes: 89 additions & 0 deletions docs/docs/users/knowledge_base/methods_filtering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
title: RPC methods filtering
---

# RPC methods filtering

## Why filter RPC methods?

When running a Filecoin node, you might want to restrict the RPC methods that are available to the clients. This can be useful for security reasons, to limit the exposure of the node to the internet, or to reduce the load on the node by disabling unnecessary methods.

:::note
[JWT authentication](./jwt_handling.md) is a different way to restrict access to the node. It allows you to authorize certain operations on the node using JWTs. However, JWT restrictions are hard-coded in the node and cannot be changed dynamically. If you want to make sure that a certain read-only method is not available to the clients, you can use the method filtering feature.

The methods are first filtered by the method filtering feature, and then the JWT authentication is applied. If a method is disallowed by the method filtering, the JWT token will not be checked for this method.
:::

## How to filter RPC methods

You need to run `forest` with the `--rpc-filter-list <PATH-TO-FILTER-LIST>` argument. If the filter list is not provided, all methods are allowed by default.

### Example

In this example, will disallow the `Filecoin.ChainExport` method which is used to export the chain to a file. This method should not be available to the clients due to its impact (compute, disk space, etc.) on the node.

1. Create a filter list file, for example, `filter-list.txt`:

```plaintext
# Disabling the snapshot exporting
!Filecoin.ChainExport
```

2. Run `forest` with the `--rpc-filter-list` argument:

```shell
forest --chain calibnet --encrypt-keystore false --rpc-filter-list filter-list.txt
```

3. Try to export the snapshot using the `forest-cli`:

```shell
forest-cli snapshot-export
```

You should see the following error:

```console
Getting ready to export...
Error: ErrorObject { code: ServerError(403), message: "Forbidden", data: None }

Caused by:
ErrorObject { code: ServerError(403), message: "Forbidden", data: None }
```

## Filter list format

The filter list is a text file where each line represents a method that should be allowed or disallowed. The format is as follows:

- `!` at the beginning of the line means that the method is disallowed.
- `#` at the beginning of the line is a comment and is ignored.
- no prefix means that the method is allowed.

If there is a single allowed method (no prefix), all other methods are disallowed by default.

:::warning
Some methods have aliases, so you need to filter all of them. This is most prominent in the `Filecoin.Eth.*` namespace. They are implemented for compatibility with Lotus, see [here](https://github.com/filecoin-project/lotus/blob/a9718c841e1fced8afc6e9fee2db2a2b565acc42/api/eth_aliases.go).
:::

## Example filter lists

Allow only the `Filecoin.StateCall` method. All other methods are disallowed:

```plaintext
Filecoin.StateCall
```

Disallow the `Filecoin.ChainExport` method. All other methods are allowed:

```plaintext
!Filecoin.ChainExport
```

Disallow the `Filecoin.EthGasPrice`, `Filecoin.EthEstimateGas`, and their aliases. All other methods are allowed:

```plaintext
!Filecoin.EthGasPrice
!eth_gasPrice
!Filecoin.EthEstimateGas
!eth_estimateGas
```
72 changes: 72 additions & 0 deletions scripts/tests/calibnet_stateless_rpc_check.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/bin/bash
set -euxo pipefail

# This script tests RPC on a stateless node. This is done to avoid downloading the snapshot for this test and speed up the CI.

source "$(dirname "$0")/harness.sh"

# Run a stateless node with a filter list as an argument.
function forest_run_node_stateless_detached_with_filter_list {
pkill -9 forest || true
local filter_list=$1

$FOREST_PATH --detach --chain calibnet --encrypt-keystore false --log-dir "$LOG_DIRECTORY" --save-token ./admin_token --skip-load-actors --stateless --rpc-filter-list "$filter_list"

ADMIN_TOKEN=$(cat admin_token)
FULLNODE_API_INFO="$ADMIN_TOKEN:/ip4/127.0.0.1/tcp/2345/http"

export ADMIN_TOKEN
export FULLNODE_API_INFO
}

# Tests the RPC method `Filecoin.ChainHead` and checks if the status code matches the expected code.
function test_rpc {
local expected_code=$1

# Test the RPC, get status code
status_code=$(curl --silent -X POST -H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","id":2,"method":"Filecoin.ChainHead","params": [ ] }' \
"http://127.0.0.1:2345/rpc/v1" | jq '.error.code')

# check if the expected code is returned
if [ "$status_code" != "$expected_code" ]; then
echo "Expected status code $expected_code, got $status_code"
exit 1
fi
}

# No filter list - all RPCs are allowed. This is the default behavior.

cat <<- EOF > "$TMP_DIR"/filter-list
# Cthulhu fhtagn
EOF

forest_run_node_stateless_detached_with_filter_list "$TMP_DIR/filter-list"
test_rpc null # null means there is no error

# Filter list with the `ChainHead` RPC disallowed. Should return 403.

cat <<- EOF > "$TMP_DIR"/filter-list
!Filecoin.ChainHead
EOF

forest_run_node_stateless_detached_with_filter_list "$TMP_DIR/filter-list"
test_rpc 403

# Filter list with a single other RPC allowed. `ChainHead` should be disallowed and return 403.
# Note - this method is required for the test harness.
cat <<- EOF > "$TMP_DIR"/filter-list
Filecoin.Shutdown
EOF

forest_run_node_stateless_detached_with_filter_list "$TMP_DIR/filter-list"
test_rpc 403

# Filter list with a single other RPC allowed, along with `ChainHead`. Should succeed.
cat <<- EOF > "$TMP_DIR"/filter-list
Filecoin.Shutdown
Filecoin.ChainHead
EOF

forest_run_node_stateless_detached_with_filter_list "$TMP_DIR/filter-list"
test_rpc null # null means there is no error
4 changes: 4 additions & 0 deletions src/cli_shared/cli/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ pub struct Client {
pub metrics_address: SocketAddr,
/// RPC bind, e.g. 127.0.0.1:1234
pub rpc_address: SocketAddr,
/// Path to a list of RPC methods to allow/disallow.
pub rpc_filter_list: Option<PathBuf>,
/// Healthcheck bind, e.g. 127.0.0.1:2346
pub healthcheck_address: SocketAddr,
/// Load actors from the bundle file (possibly generating it if it doesn't exist)
pub load_actors: bool,
Expand All @@ -87,6 +90,7 @@ impl Default for Client {
encrypt_keystore: true,
metrics_address: FromStr::from_str("0.0.0.0:6116").unwrap(),
rpc_address: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), crate::rpc::DEFAULT_PORT),
rpc_filter_list: None,
healthcheck_address: SocketAddr::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
crate::health::DEFAULT_HEALTHCHECK_PORT,
Expand Down
4 changes: 4 additions & 0 deletions src/cli_shared/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ pub struct CliOpts {
/// Address used for RPC. By defaults binds on localhost on port 2345.
#[arg(long)]
pub rpc_address: Option<SocketAddr>,
/// Path to a list of RPC methods to allow/disallow.
#[arg(long)]
pub rpc_filter_list: Option<PathBuf>,
/// Disable healthcheck endpoints
#[arg(long)]
pub no_healthcheck: bool,
Expand Down Expand Up @@ -165,6 +168,7 @@ impl CliOpts {
}
if self.rpc.unwrap_or(cfg.client.enable_rpc) {
cfg.client.enable_rpc = true;
cfg.client.rpc_filter_list = self.rpc_filter_list.clone();
if let Some(rpc_address) = self.rpc_address {
cfg.client.rpc_address = rpc_address;
}
Expand Down
7 changes: 7 additions & 0 deletions src/daemon/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,12 @@ pub(super) async fn start(
let keystore_rpc = Arc::clone(&keystore);
let rpc_state_manager = Arc::clone(&state_manager);
let rpc_address = config.client.rpc_address;
let filter_list = config
.client
.rpc_filter_list
.as_ref()
.map(|path| crate::rpc::FilterList::new_from_file(path))
.transpose()?;

info!("JSON-RPC endpoint will listen at {rpc_address}");

Expand All @@ -418,6 +424,7 @@ pub(super) async fn start(
tipset_send: tipset_sender,
},
rpc_address,
filter_list,
)
.await
});
Expand Down
71 changes: 71 additions & 0 deletions src/rpc/filter_layer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright 2019-2025 ChainSafe Systems
// SPDX-License-Identifier: Apache-2.0, MIT

use std::sync::Arc;

use futures::future::BoxFuture;
use futures::FutureExt;
use jsonrpsee::server::middleware::rpc::RpcServiceT;
use jsonrpsee::types::ErrorObject;
use jsonrpsee::MethodResponse;
use tower::Layer;

use super::FilterList;

/// JSON-RPC middleware layer for filtering RPC methods based on their name.
#[derive(Clone, Default)]
pub(super) struct FilterLayer {
filter_list: Arc<FilterList>,
}

impl FilterLayer {
pub fn new(filter_list: FilterList) -> Self {
Self {
filter_list: Arc::new(filter_list),
}
}
}

impl<S> Layer<S> for FilterLayer {
type Service = Filtering<S>;

fn layer(&self, service: S) -> Self::Service {
Filtering {
service,
filter_list: self.filter_list.clone(),
}
}
}

#[derive(Clone)]
pub(super) struct Filtering<S> {
service: S,
filter_list: Arc<FilterList>,
}

impl<'a, S> RpcServiceT<'a> for Filtering<S>
where
S: RpcServiceT<'a> + Send + Sync + Clone + 'static,
{
type Future = BoxFuture<'a, MethodResponse>;

fn call(&self, req: jsonrpsee::types::Request<'a>) -> Self::Future {
let service = self.service.clone();
let authorized = self.filter_list.authorize(req.method_name());
async move {
if authorized {
service.call(req).await
} else {
MethodResponse::error(
req.id(),
ErrorObject::borrowed(
http::StatusCode::FORBIDDEN.as_u16() as _,
"Forbidden",
None,
),
)
}
}
.boxed()
}
}
Loading
Loading