Skip to content

Commit

Permalink
feat: add an optional password header to make authenticated rpc calls (
Browse files Browse the repository at this point in the history
  • Loading branch information
carneiro-cw authored Dec 20, 2024
1 parent 0d2f8ed commit ec32198
Show file tree
Hide file tree
Showing 12 changed files with 195 additions and 24 deletions.
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

0 comments on commit ec32198

Please sign in to comment.