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: add an optional password header to make authenticated rpc calls #1928

Merged
merged 2 commits into from
Dec 20, 2024
Merged
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
10 changes: 10 additions & 0 deletions .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,13 @@ jobs:
concurrency:
group: ${{ github.workflow }}-clock-rocks-${{ github.ref || github.run_id }}
cancel-in-progress: true

e2e-admin-password:
name: E2E Admin Password
uses: ./.github/workflows/_setup-e2e.yml
with:
justfile_recipe: "e2e-admin-password"

concurrency:
group: ${{ github.workflow }}-admin-password-${{ github.ref || github.run_id }}
cancel-in-progress: true
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ stratus.log

# OS specific
.DS_Store
.aider*
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe("Leader & Follower change integration test", function () {
const version = await sendWithRetry("stratus_version", []);
expect(version).to.have.nested.property("git.commit");
expect(version.git.commit).to.be.a("string");
expect(version.git.commit).to.have.lengthOf(7);
expect(version.git.commit.length).to.be.oneOf([7, 8]);
});

it("Validate initial Follower state, health and version", async function () {
Expand All @@ -32,7 +32,7 @@ describe("Leader & Follower change integration test", function () {
const version = await sendWithRetry("stratus_version", []);
expect(version).to.have.nested.property("git.commit");
expect(version.git.commit).to.be.a("string");
expect(version.git.commit).to.have.lengthOf(7);
expect(version.git.commit.length).to.be.oneOf([7, 8]);
});

it("Change Leader to Leader should return false", async function () {
Expand Down
26 changes: 26 additions & 0 deletions e2e/test/admin/e2e-admin-password-disabled.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { expect } from "chai";

import { send, sendReset } from "../helpers/rpc";

describe("Admin Password (without password set)", () => {
before(async () => {
await sendReset();
});

it("should accept requests without password", async () => {
const result = await send("stratus_enableTransactions", []);
expect(result).to.be.true;

// Cleanup - disable transactions
await send("stratus_disableTransactions", []);
});

it("should accept requests with any password", async () => {
const headers = { Authorization: "Password random123" };
const result = await send("stratus_enableTransactions", [], headers);
expect(result).to.be.true;

// Cleanup - disable transactions
await send("stratus_disableTransactions", [], headers);
});
});
32 changes: 32 additions & 0 deletions e2e/test/admin/e2e-admin-password-enabled.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect } from "chai";

import { send, sendAndGetError, sendReset } from "../helpers/rpc";

describe("Admin Password (with password set)", () => {
before(async () => {
await sendReset();
});

it("should reject requests without password", async () => {
const error = await sendAndGetError("stratus_enableTransactions", []);
console.log(error);
expect(error.code).eq(-32009); // Internal error
expect(error.message).to.contain("Incorrect password");
});

it("should reject requests with wrong password", async () => {
const headers = { Authorization: "Password wrong123" };
const error = await sendAndGetError("stratus_enableTransactions", [], headers);
expect(error.code).eq(-32009); // Internal error
expect(error.message).to.contain("Incorrect password");
});

it("should accept requests with correct password", async () => {
const headers = { Authorization: "Password test123" };
const result = await send("stratus_enableTransactions", [], headers);
expect(result).to.be.true;

// Cleanup - disable transactions
await send("stratus_disableTransactions", [], headers);
});
});
26 changes: 20 additions & 6 deletions e2e/test/helpers/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,11 @@ if (process.env.RPC_LOG) {
// Sends an RPC request to the blockchain, returning full response.
let requestId = 0;

export async function sendAndGetFullResponse(method: string, params: any[] = []): Promise<any> {
export async function sendAndGetFullResponse(
method: string,
params: any[] = [],
headers: Record<string, string> = {},
): Promise<any> {
for (let i = 0; i < params.length; ++i) {
const param = params[i];
if (param instanceof Account) {
Expand All @@ -130,8 +134,14 @@ export async function sendAndGetFullResponse(method: string, params: any[] = [])
console.log("REQ ->", JSON.stringify(payload));
}

// prepare headers
const requestHeaders = {
"Content-Type": "application/json",
...headers,
};

// execute request and log response
const response = await axios.post(providerUrl, payload, { headers: { "Content-Type": "application/json" } });
const response = await axios.post(providerUrl, payload, { headers: requestHeaders });
if (process.env.RPC_LOG) {
console.log("RESP <-", JSON.stringify(response.data));
}
Expand All @@ -140,15 +150,19 @@ export async function sendAndGetFullResponse(method: string, params: any[] = [])
}

// Sends an RPC request to the blockchain, returning its result field.
export async function send(method: string, params: any[] = []): Promise<any> {
const response = await sendAndGetFullResponse(method, params);
export async function send(method: string, params: any[] = [], headers: Record<string, string> = {}): Promise<any> {
const response = await sendAndGetFullResponse(method, params, headers);
return response.data.result;
}

// Sends an RPC request to the blockchain, returning its error field.
// Use it when you expect the RPC call to fail.
export async function sendAndGetError(method: string, params: any[] = []): Promise<any> {
const response = await sendAndGetFullResponse(method, params);
export async function sendAndGetError(
method: string,
params: any[] = [],
headers: Record<string, string> = {},
): Promise<any> {
const response = await sendAndGetFullResponse(method, params, headers);
return response.data.error;
}

Expand Down
21 changes: 20 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,25 @@ e2e network="stratus" block_modes="automine" test="":
fi
done

# E2E: Execute admin password tests
e2e-admin-password:
#!/bin/bash
cd e2e

# Start Stratus with password set
just _log "Running admin password tests with password set"
ADMIN_PASSWORD=test123 just run -a 0.0.0.0:3000 > /dev/null &
just _wait_for_stratus
npx hardhat test test/admin/e2e-admin-password-enabled.test.ts --network stratus
killport 3000

# Start Stratus without password set
just _log "Running admin password tests without password set"
just run -a 0.0.0.0:3000 > /dev/null &
just _wait_for_stratus
npx hardhat test test/admin/e2e-admin-password-disabled.test.ts --network stratus
killport 3000

# E2E: Starts and execute Hardhat tests in Hardhat
e2e-hardhat block-mode="automine" test="":
#!/bin/bash
Expand Down Expand Up @@ -599,4 +618,4 @@ stratus-test-coverage *args="":
-rm utils/deploy/deploy_02.log
*/

cargo llvm-cov report {{args}}
cargo llvm-cov report {{args}}
4 changes: 4 additions & 0 deletions src/eth/primitives/stratus_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ pub enum StratusError {
#[strum(props(kind = "server_state"))]
StratusNotFollower,

#[error("Incorrect password, cancelling operation.")]
#[strum(props(kind = "server_state"))]
InvalidPassword,

#[error("Stratus node is already in the process of changing mode.")]
#[strum(props(kind = "server_state"))]
ModeChangeInProgress,
Expand Down
35 changes: 35 additions & 0 deletions src/eth/rpc/rpc_http_middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use reqwest::header::HeaderMap;
use reqwest::header::HeaderValue;
use tower::Service;

use crate::eth::primitives::StratusError;
use crate::eth::rpc::RpcClientApp;
use crate::ext::not;

Expand All @@ -36,12 +37,46 @@ where

fn call(&mut self, mut request: HttpRequest<HttpBody>) -> Self::Future {
let client_app = parse_client_app(request.headers(), request.uri());
let authentication = parse_admin_password(request.headers());
request.extensions_mut().insert(client_app);
request.extensions_mut().insert(authentication);

Box::pin(self.service.call(request).map_err(Into::into))
}
}

#[derive(Debug, Clone)]
pub enum Authentication {
Admin,
None,
}

impl Authentication {
pub fn auth_admin(&self) -> Result<(), StratusError> {
if matches!(self, Authentication::Admin) {
return Ok(());
}
Err(StratusError::InvalidPassword)
}
}

/// Checks if the provided admin password is correct
fn parse_admin_password(headers: &HeaderMap<HeaderValue>) -> Authentication {
let real_pass = match std::env::var("ADMIN_PASSWORD") {
Ok(pass) if !pass.is_empty() => pass,
_ => return Authentication::Admin,
};

match headers
.get("Authorization")
.and_then(|val| val.to_str().ok())
.and_then(|val| val.strip_prefix("Password "))
{
Some(password) if password == real_pass => Authentication::Admin,
_ => Authentication::None,
}
}

/// Extracts the client application name from the `app` query parameter.
fn parse_client_app(headers: &HeaderMap<HeaderValue>, uri: &Uri) -> RpcClientApp {
fn try_query_params(uri: &Uri) -> Option<RpcClientApp> {
Expand Down
3 changes: 3 additions & 0 deletions src/eth/rpc/rpc_middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ impl<'a> RpcServiceT<'a> for RpcMiddleware {
_ => None,
};

let is_admin = request.extensions.is_admin();

let client = if let Some(tx_client) = tx.as_ref().and_then(|tx| tx.client.as_ref()) {
let val = tx_client.clone();
request.extensions_mut().insert(val);
Expand Down Expand Up @@ -115,6 +117,7 @@ impl<'a> RpcServiceT<'a> for RpcMiddleware {
rpc_tx_function = %tx.as_ref().map(|tx|tx.function).or_empty(),
rpc_tx_from = %tx.as_ref().and_then(|tx|tx.from).or_empty(),
rpc_tx_to = %tx.as_ref().and_then(|tx|tx.to).or_empty(),
is_admin = %is_admin,
"rpc request"
);

Expand Down
15 changes: 15 additions & 0 deletions src/eth/rpc/rpc_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use jsonrpsee::Extensions;
use rlp::Decodable;
use tracing::Span;

use super::rpc_http_middleware::Authentication;
use crate::eth::primitives::StratusError;
use crate::eth::rpc::rpc_client_app::RpcClientApp;
use crate::ext::type_basename;
Expand All @@ -15,6 +16,12 @@ pub trait RpcExtensionsExt {
/// Returns the client performing the JSON-RPC request.
fn rpc_client(&self) -> &RpcClientApp;

/// Returns current Authentication.
fn authentication(&self) -> &Authentication;

/// Returns wheather admin authentication suceeded.
fn is_admin(&self) -> bool;

/// Enters RpcMiddleware request span if present.
fn enter_middleware_span(&self) -> Option<EnteredWrap<'_>>;
}
Expand All @@ -24,6 +31,14 @@ impl RpcExtensionsExt for Extensions {
self.get::<RpcClientApp>().unwrap_or(&RpcClientApp::Unknown)
}

fn authentication(&self) -> &Authentication {
self.get::<Authentication>().unwrap_or(&Authentication::None)
}

fn is_admin(&self) -> bool {
matches!(self.authentication(), Authentication::Admin)
}

fn enter_middleware_span(&self) -> Option<EnteredWrap<'_>> {
self.get::<Span>().map(|s| s.enter()).map(EnteredWrap::new)
}
Expand Down
Loading
Loading